From 5ba227c7cd3ddfcd3bffc3fd15daf8d6ec9b8713 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 29 Jun 2011 18:30:39 +0200 Subject: feat($location): $location service with html5 history api support See documentation of $location for more info Breaks $location has no properties, only get/set methods Closes #168 Closes #146 Closes #281 Closes #234 --- src/service/location.js | 646 +++++++++++++++++++++++++------------- test/service/locationSpec.js | 722 ++++++++++++++++++++++++++++++++----------- 2 files changed, 975 insertions(+), 393 deletions(-) diff --git a/src/service/location.js b/src/service/location.js index 2f53f520..9a1afc37 100644 --- a/src/service/location.js +++ b/src/service/location.js @@ -1,275 +1,505 @@ 'use strict'; var URL_MATCH = /^(file|ftp|http|https):\/\/(\w+:{0,1}\w*@)?([\w\.-]*)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/, - HASH_MATCH = /^([^\?]*)?(\?([^\?]*))?$/, - DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp':21}; + PATH_MATCH = /^([^\?#]*)?(\?([^#]*))?(#(.*))?$/, + HASH_MATCH = PATH_MATCH, + DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp': 21}; + /** - * @workInProgress - * @ngdoc service - * @name angular.service.$location - * @requires $browser - * - * @property {string} href The full URL of the current location. - * @property {string} protocol The protocol part of the URL (e.g. http or https). - * @property {string} host The host name, ip address or FQDN of the current location. - * @property {number} port The port number of the current location (e.g. 80, 443, 8080). - * @property {string} path The path of the current location (e.g. /myapp/inbox). - * @property {Object.} search Map of query parameters (e.g. {user:"foo", page:23}). - * @property {string} hash The fragment part of the URL of the current location (e.g. #foo). - * @property {string} hashPath Similar to `path`, but located in the `hash` fragment - * (e.g. ../foo#/some/path => /some/path). - * @property {Object.} hashSearch Similar to `search` but located in `hash` - * fragment (e.g. .../foo#/some/path?hashQuery=param => {hashQuery: "param"}). + * Encode path using encodeUriSegment, ignoring forward slashes * - * @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 in the browser location url. + * @param {string} path Path to encode + * @returns {string} + */ +function encodePath(path) { + var segments = path.split('/'), + i = segments.length; + + while (i--) { + segments[i] = encodeUriSegment(segments[i]); + } + + return segments.join('/'); +} + + +function matchUrl(url, obj) { + var match = URL_MATCH.exec(url), + + match = { + protocol: match[1], + host: match[3], + port: parseInt(match[5]) || DEFAULT_PORTS[match[1]] || null, + path: match[6] || '/', + search: match[8], + hash: match[10] + }; + + if (obj) { + obj.$$protocol = match.protocol; + obj.$$host = match.host; + obj.$$port = match.port; + } + + return match; +} + + +function composeProtocolHostPort(protocol, host, port) { + return protocol + '://' + host + (port == DEFAULT_PORTS[protocol] ? '' : ':' + port); +} + + +function pathPrefixFromBase(basePath) { + return basePath.substr(0, basePath.lastIndexOf('/')); +} + + +function convertToHtml5Url(url, basePath, hashPrefix) { + var match = matchUrl(url); + + // already html5 url + if (decodeURIComponent(match.path) != basePath || isUndefined(match.hash) || + match.hash.indexOf(hashPrefix) != 0) { + return url; + // convert hashbang url -> html5 url + } else { + return composeProtocolHostPort(match.protocol, match.host, match.port) + + pathPrefixFromBase(basePath) + match.hash.substr(hashPrefix.length); + } +} + + +function convertToHashbangUrl(url, basePath, hashPrefix) { + var match = matchUrl(url); + + // already hashbang url + if (decodeURIComponent(match.path) == basePath) { + return url; + // convert html5 url -> hashbang url + } else { + var search = match.search && '?' + match.search || '', + hash = match.hash && '#' + match.hash || '', + pathPrefix = pathPrefixFromBase(basePath), + path = match.path.substr(pathPrefix.length); + + if (match.path.indexOf(pathPrefix) != 0) { + throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !'; + } + + return composeProtocolHostPort(match.protocol, match.host, match.port) + basePath + + '#' + hashPrefix + path + search + hash; + } +} + + +/** + * LocationUrl represents an url + * This object is exposed as $location service when html5 is enabled and supported * - * Notice that using browser's forward/back buttons changes the $location. + * @constructor + * @param {string} url Html5 url + * @param {string} pathPrefix + */ +function LocationUrl(url, pathPrefix) { + pathPrefix = pathPrefix || ''; + + /** + * Parse given html5 (regular) url string into properties + * @param {string} url Html5 url + * @private + */ + this.$$parse = function(url) { + var match = matchUrl(url, this); + + if (match.path.indexOf(pathPrefix) != 0) { + throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !'; + } + + this.$$path = decodeURIComponent(match.path.substr(pathPrefix.length)); + this.$$search = parseKeyValue(match.search); + this.$$hash = match.hash && decodeURIComponent(match.hash) || ''; + + this.$$compose(); + }, + + /** + * Compose url and update `absUrl` property + * @private + */ + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; + + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) + + pathPrefix + this.$$url; + }; + + this.$$parse(url); +} + + +/** + * LocationHashbangUrl represents url + * This object is exposed as $location service when html5 history api is disabled or not supported * - * @example - - -
- test hash| - reset hash
- -
$location = {{$location}}
-
-
- - it('should initialize the input field', function() { - expect(using('.doc-example-live').input('$location.hash').val()). - toBe('!/api/angular.service.$location'); - }); - - - it('should bind $location.hash to the input field', function() { - using('.doc-example-live').input('$location.hash').enter('foo'); - expect(browser().location().hash()).toBe('foo'); - }); - - - it('should set the hash to a test string with test link is presed', function() { - using('.doc-example-live').element('#ex-test').click(); - expect(using('.doc-example-live').input('$location.hash').val()). - toBe('myPath?name=misko'); - }); - - it('should reset $location when reset link is pressed', function() { - using('.doc-example-live').input('$location.hash').enter('foo'); - using('.doc-example-live').element('#ex-reset').click(); - expect(using('.doc-example-live').input('$location.hash').val()). - toBe('!/api/angular.service.$location'); - }); - - -
+ * @constructor + * @param {string} url Legacy url + * @param {string} hashPrefix Prefix for hash part (containing path and search) */ -angularServiceInject("$location", function($browser) { - var location = {update: update, updateHash: updateHash}; - var lastLocation = {}; // last state since last update(). +function LocationHashbangUrl(url, hashPrefix) { + var basePath; + + /** + * Parse given hashbang url into properties + * @param {string} url Hashbang url + * @private + */ + this.$$parse = function(url) { + var match = matchUrl(url, this); + + if (match.hash && match.hash.indexOf(hashPrefix) != 0) { + throw 'Invalid url "' + url + '", missing hash prefix "' + hashPrefix + '" !'; + } + + basePath = match.path + (match.search ? '?' + match.search : ''); + match = HASH_MATCH.exec((match.hash || '').substr(hashPrefix.length)); + if (match[1]) { + this.$$path = (match[1].charAt(0) == '/' ? '' : '/') + decodeURIComponent(match[1]); + } else { + this.$$path = ''; + } + + this.$$search = parseKeyValue(match[3]); + this.$$hash = match[5] && decodeURIComponent(match[5]) || ''; + + this.$$compose(); + }; + + /** + * Compose hashbang url and update `absUrl` property + * @private + */ + this.$$compose = function() { + var search = toKeyValue(this.$$search), + hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; - $browser.onUrlChange(bind(this, this.$apply, function() { //register - update($browser.url()); - }))(); //initialize + this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; + this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) + + basePath + (this.$$url ? '#' + hashPrefix + this.$$url : ''); + }; - this.$watch(sync); + this.$$parse(url); +} - return location; - // PUBLIC METHODS +LocationUrl.prototype = LocationHashbangUrl.prototype = { + + /** + * Has any change been replacing ? + * @private + */ + $$replace: false, /** - * @workInProgress * @ngdoc method - * @name angular.service.$location#update + * @name angular.service.$location#absUrl * @methodOf angular.service.$location * * @description - * Updates the location object. - * Does not immediately update the browser - * Browser is updated at the end of $digest() + * This method is getter only. * - * Does not immediately update the browser. Instead the browser is updated at the end of $eval() - * cycle. + * Return full url representation with all segments encoded according to rules specified in + * {@link http://www.ietf.org/rfc/rfc3986.txt RFC 3986}. * - *
-       $location.update('http://www.angularjs.org/path#hash?search=x');
-       $location.update({host: 'www.google.com', protocol: 'https'});
-       $location.update({hashPath: '/path', hashSearch: {a: 'b', x: true}});
-     
- * - * @param {string|Object} href Full href as a string or object with properties + * @return {string} */ - 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); - } - $browser.url(location.href); - copy(location, lastLocation); - } + absUrl: locationGetter('$$absUrl'), /** - * @workInProgress * @ngdoc method - * @name angular.service.$location#updateHash + * @name angular.service.$location#url * @methodOf angular.service.$location * * @description - * Updates the hash fragment part of the url. + * This method is getter / setter. * - * @see update() + * Return url (e.g. `/path?a=b#hash`) when called without any parameter. * - *
-       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}})
-     
+ * Change path, search and hash, when called with parameter and return `$location`. * - * @param {string|Object} path A hashPath or hashSearch object - * @param {Object=} search A hashSearch object + * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) + * @return {string} */ - function updateHash(path, search) { - var hash = {}; + url: function(url, replace) { + if (isUndefined(url)) + return this.$$url; - if (isString(path)) { - hash.hashPath = path; - hash.hashSearch = search || {}; - } else - hash.hashSearch = path; + var match = PATH_MATCH.exec(url); + this.path(decodeURIComponent(match[1] || '')).search(match[3] || '') + .hash(match[5] || '', replace); - hash.hash = composeHash(hash); + return this; + }, - update({hash: hash}); - } + /** + * @ngdoc method + * @name angular.service.$location#protocol + * @methodOf angular.service.$location + * + * @description + * This method is getter only. + * + * Return protocol of current url. + * + * @return {string} + */ + protocol: locationGetter('$$protocol'), + /** + * @ngdoc method + * @name angular.service.$location#host + * @methodOf angular.service.$location + * + * @description + * This method is getter only. + * + * Return host of current url. + * + * @return {string} + */ + host: locationGetter('$$host'), - // INNER METHODS + /** + * @ngdoc method + * @name angular.service.$location#port + * @methodOf angular.service.$location + * + * @description + * This method is getter only. + * + * Return port of current url. + * + * @return {Number} + */ + port: locationGetter('$$port'), /** - * Synchronizes all location object properties. + * @ngdoc method + * @name angular.service.$location#path + * @methodOf angular.service.$location * - * User is allowed to change properties, so after property change, - * location object is not in consistent state. + * @description + * This method is getter / setter. * - * Properties are synced with the following precedence order: + * Return path of current url when called without any parameter. * - * - `$location.href` - * - `$location.hash` - * - everything else + * Change path when called with parameter and return `$location`. * - * Keep in mind that if the following code is executed: + * Note: Path should always begin with forward slash (/), this method will add the forward slash + * if it is missing. * - * scope.$location.href = 'http://www.angularjs.org/path#a/b' + * @param {string=} path New path + * @return {string} + */ + path: locationGetterSetter('$$path', function(path) { + return path.charAt(0) == '/' ? path : '/' + path; + }), + + /** + * @ngdoc method + * @name angular.service.$location#search + * @methodOf angular.service.$location + * + * @description + * This method is getter / setter. + * + * Return search part (as object) of current url when called without any parameter. * - * immediately afterwards all other properties are still the old ones... + * Change search part when called with parameter and return `$location`. * - * This method checks the changes and update location to the consistent state + * @param {string|object=} search New search part - string or hash object + * @return {string} */ - function sync() { - if (!equals(location, lastLocation)) { - if (location.href != lastLocation.href) { - update(location.href); + search: function(search, paramValue) { + if (isUndefined(search)) + return this.$$search; + + if (isDefined(paramValue)) { + if (paramValue === null) { + delete this.$$search[search]; } else { - 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); + this.$$search[search] = escape(paramValue); } + } else { + this.$$search = isString(search) ? parseKeyValue(search) : search; } - } + this.$$compose(); + return this; + }, /** - * Compose href string from a location object + * @ngdoc method + * @name angular.service.$location#hash + * @methodOf angular.service.$location + * + * @description + * This method is getter / setter. + * + * Return hash fragment when called without any parameter. * - * @param {Object} loc The location object with all properties - * @return {string} Composed href + * Change hash fragment when called with parameter and return `$location`. + * + * @param {string=} hash New hash fragment + * @return {string} */ - 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 : ''); - } + hash: locationGetterSetter('$$hash', identity), /** - * Compose hash string from location object + * @ngdoc method + * @name angular.service.$location#replace + * @methodOf angular.service.$location * - * @param {Object} loc Object with hashPath and hashSearch properties - * @return {string} Hash string + * @description + * If called, all changes to $location during current `$digest` will be replacing current history + * record, instead of adding new one. */ - 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 : ''); + replace: function() { + this.$$replace = true; + return this; } +}; - /** - * 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)); + +function locationGetter(property) { + return function() { + return this[property]; + }; +} + + +function locationGetterSetter(property, preprocess) { + return function(value) { + if (isUndefined(value)) + return this[property]; + + this[property] = preprocess(value); + this.$$compose(); + + return this; + }; +} + + +/** + * @ngdoc service + * @name angular.service.$location + * + * @requires $browser + * @requires $sniffer + * @requires $config + * @requires $document + * + * @description + * The $location service parses the URL in the browser address bar (based on the {@link https://developer.mozilla.org/en/window.location window.location}) and makes the URL available to your application. Changes to the URL in the address bar are reflected into $location service and changes to $location are reflected into the browser address bar. + * + * **The $location service:** + * + * - Exposes the current URL in the browser address bar, so you can + * - Watch and observe the URL. + * - Change the URL. + * - Synchronizes the URL with the browser when the user + * - Changes the address bar. + * - Clicks the back or forward button (or clicks a History link). + * - Clicks on a link. + * - Represents the URL object as a set of methods (protocol, host, port, path, search, hash). + * + * For more information see {@link guide/dev_guide.services.$location Developer Guide: Angular Services: Using $location} + */ +angularServiceInject('$location', function($browser, $sniffer, $config, $document) { + var scope = this, currentUrl, + basePath = $browser.baseHref() || '/', + pathPrefix = pathPrefixFromBase(basePath), + hashPrefix = $config.hashPrefix || '', + initUrl = $browser.url(); + + if ($config.html5Mode) { + if ($sniffer.history) { + currentUrl = new LocationUrl(convertToHtml5Url(initUrl, basePath, hashPrefix), pathPrefix); + } else { + currentUrl = new LocationHashbangUrl(convertToHashbangUrl(initUrl, basePath, hashPrefix), + hashPrefix); } - return loc; + // link rewriting + var u = currentUrl, + absUrlPrefix = composeProtocolHostPort(u.protocol(), u.host(), u.port()) + pathPrefix; + + $document.bind('click', function(event) { + // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) + // currently we open nice url link and redirect then + + if (uppercase(event.target.nodeName) != 'A' || event.ctrlKey || event.which == 2) return; + + var elm = jqLite(event.target), + href = elm.attr('href'); + + if (!href || isDefined(elm.attr('ng:ext-link')) || elm.attr('target')) return; + + // remove same domain from full url links (IE7 always returns full hrefs) + href = href.replace(absUrlPrefix, ''); + + // link to different domain (or base path) + if (href.substr(0, 4) == 'http') return; + + // remove pathPrefix from absolute links + href = href.indexOf(pathPrefix) === 0 ? href.substr(pathPrefix.length) : href; + + currentUrl.url(href); + scope.$apply(); + event.preventDefault(); + }); + } else { + currentUrl = new LocationHashbangUrl(initUrl, hashPrefix); } - /** - * 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]); + // rewrite hashbang url <> html5 url + if (currentUrl.absUrl() != initUrl) { + $browser.url(currentUrl.absUrl(), true); + } + + // update $location when $browser url changes + $browser.onUrlChange(function(newUrl) { + if (currentUrl.absUrl() != newUrl) { + currentUrl.$$parse(newUrl); + scope.$apply(); + } + }); + + // update browser + var changeCounter = 0; + scope.$watch(function() { + if ($browser.url() != currentUrl.absUrl()) { + changeCounter++; + scope.$evalAsync(function() { + $browser.url(currentUrl.absUrl(), currentUrl.$$replace); + currentUrl.$$replace = false; + }); } - return h; - } -}, ['$browser']); + return changeCounter; + }); + + return currentUrl; +}, ['$browser', '$sniffer', '$locationConfig', '$document']); + + +angular.service('$locationConfig', function() { + return { + html5Mode: false, + hashPrefix: '' + }; +}); diff --git a/test/service/locationSpec.js b/test/service/locationSpec.js index a137149d..e798aa81 100644 --- a/test/service/locationSpec.js +++ b/test/service/locationSpec.js @@ -1,251 +1,463 @@ 'use strict'; +/** + * Create jasmine.Spy on given method, but ignore calls without arguments + * This is helpful when need to spy only setter methods and ignore getters + */ +function spyOnlyCallsWithArgs(obj, method) { + var spy = spyOn(obj, method); + obj[method] = function() { + if (arguments.length) return spy.apply(this, arguments); + return spy.originalValue.apply(this); + }; + return spy; +} + + describe('$location', function() { - var scope, $location, $browser; + var url; - beforeEach(function(){ - scope = angular.scope(); - $location = scope.$service('$location'); - $browser = scope.$service('$browser'); - }); + describe('NewUrl', function() { + beforeEach(function() { + url = new LocationUrl('http://www.domain.com:9877/path/b?search=a&b=c&d#hash'); + }); - afterEach(function(){ - dealoc(scope); - }); + it('should provide common getters', function() { + expect(url.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#hash'); + expect(url.protocol()).toBe('http'); + expect(url.host()).toBe('www.domain.com'); + expect(url.port()).toBe(9877); + expect(url.path()).toBe('/path/b'); + expect(url.search()).toEqual({search: 'a', b: 'c', d: true}); + expect(url.hash()).toBe('hash'); + expect(url.url()).toBe('/path/b?search=a&b=c&d#hash'); + }); - 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('path() should change path', function() { + url.path('/new/path'); + expect(url.path()).toBe('/new/path'); + expect(url.absUrl()).toBe('http://www.domain.com:9877/new/path?search=a&b=c&d#hash'); + }); - it('should update location when browser url changed', function() { - var origUrl = $location.href; - expect(origUrl).toEqual($browser.url()); + it('search() should accept string', function() { + url.search('x=y&c'); + expect(url.search()).toEqual({x: 'y', c: true}); + expect(url.absUrl()).toBe('http://www.domain.com:9877/path/b?x=y&c#hash'); + }); - var newUrl = 'http://somenew/url#foo'; - $browser.url(newUrl); - $browser.poll(); - expect($location.href).toEqual(newUrl); - }); + it('search() should accept object', function() { + url.search({one: 1, two: true}); + expect(url.search()).toEqual({one: 1, two: true}); + expect(url.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two#hash'); + }); - it('should update browser at the end of $eval', function() { - var origBrowserUrl = $browser.url(); - $location.update('http://www.angularjs.org/'); - $location.update({path: '/a/b'}); - expect($location.href).toEqual('http://www.angularjs.org/a/b'); - expect($browser.url()).toEqual('http://www.angularjs.org/a/b'); - $location.path = '/c'; - scope.$digest(); - expect($browser.url()).toEqual('http://www.angularjs.org/c'); - }); + it('search() should change single parameter', function() { + url.search({id: 'old', preserved: true}); + url.search('id', 'new'); + + expect(url.search()).toEqual({id: 'new', preserved: true}); + }); - 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('search() should remove single parameter', function() { + url.search({id: 'old', preserved: true}); + url.search('id', null); + expect(url.search()).toEqual({preserved: true}); + }); - it('should update hash on hashPath or hashSearch update', function() { - $location.update('http://server/#path?a=b'); - scope.$digest(); - $location.update({hashPath: '', hashSearch: {}}); - expect($location.hash).toEqual(''); - }); + it('hash() should change hash fragment', function() { + url.hash('new-hash'); + expect(url.hash()).toBe('new-hash'); + expect(url.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#new-hash'); + }); - it('should update hashPath and hashSearch on $location.hash change upon eval', function(){ - $location.update('http://server/#path?a=b'); - scope.$digest(); + it('url() should change the path, search and hash', function() { + url.url('/some/path?a=b&c=d#hhh'); + expect(url.url()).toBe('/some/path?a=b&c=d#hhh'); + expect(url.absUrl()).toBe('http://www.domain.com:9877/some/path?a=b&c=d#hhh'); + expect(url.path()).toBe('/some/path'); + expect(url.search()).toEqual({a: 'b', c: 'd'}); + expect(url.hash()).toBe('hhh'); + }); - $location.hash = ''; - scope.$digest(); - expect($location.href).toEqual('http://server/'); - expect($location.hashPath).toEqual(''); - expect($location.hashSearch).toEqual({}); - }); + it('replace should set $$replace flag and return itself', function() { + expect(url.$$replace).toBe(false); + url.replace(); + expect(url.$$replace).toBe(true); + expect(url.replace()).toBe(url); + }); - it('should update hash on $location.hashPath or $location.hashSearch change upon eval', - function() { - $location.update('http://server/#path?a=b'); - expect($location.href).toEqual('http://server/#path?a=b'); - expect($location.hashPath).toEqual('path'); - expect($location.hashSearch).toEqual({a:'b'}); - $location.hashPath = ''; - $location.hashSearch = {}; - scope.$digest(); + it('should parse new url', function() { + url = new LocationUrl('http://host.com/base'); + expect(url.path()).toBe('/base'); - expect($location.href).toEqual('http://server/'); - expect($location.hash).toEqual(''); - }); + url = new LocationUrl('http://host.com/base#'); + expect(url.path()).toBe('/base'); + }); - it('should sync $location upon eval before watches are fired', function(){ - scope.$location = scope.$service('$location'); //publish to the scope for $watch + it('should prefix path with forward-slash', function() { + url = new LocationUrl('http://server/a'); + url.path('b'); - var log = ''; - scope.$watch('$location.hash', function(scope){ - log += scope.$location.hashPath + ';'; + expect(url.path()).toBe('/b'); + expect(url.absUrl()).toBe('http://server/b'); }); - expect(log).toEqual(''); - scope.$digest(); - expect(log).toEqual(';'); - log = ''; - scope.$location.hash = '/abc'; - scope.$digest(); - expect(scope.$location.hash).toEqual('/abc'); - expect(log).toEqual('/abc;'); - }); + it('should set path to forward-slash when empty', function() { + url = new LocationUrl('http://server'); + expect(url.path()).toBe('/'); + expect(url.absUrl()).toBe('http://server/'); + }); - describe('sync', function() { - it('should update hash with escaped hashPath', function() { - $location.hashPath = 'foo=bar'; - scope.$digest(); - expect($location.hash).toBe('foo%3Dbar'); + it('setters should return Url object to allow chaining', function() { + expect(url.path('/any')).toBe(url); + expect(url.search('')).toBe(url); + expect(url.hash('aaa')).toBe(url); + expect(url.url('/some')).toBe(url); }); - 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'; + it('should not preserve old properties when parsing new url', function() { + url.$$parse('http://www.domain.com:9877/a'); - scope.$digest(); + expect(url.path()).toBe('/a'); + expect(url.search()).toEqual({}); + expect(url.hash()).toBe(''); + expect(url.absUrl()).toBe('http://www.domain.com:9877/a'); + }); - expect($location).toEqualData({href: 'https://hrefhost:23/hrefpath', - protocol: 'https', - host: 'hrefhost', - port: '23', - path: '/hrefpath', - search: {}, - hash: '', - hashPath: '', - hashSearch: {} - }); + + it('should prepend path with basePath', function() { + url = new LocationUrl('http://server/base/abc?a', '/base'); + expect(url.path()).toBe('/abc'); + expect(url.search()).toEqual({a: true}); + + url.path('/new/path'); + expect(url.absUrl()).toBe('http://server/base/new/path?a'); }); - 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'; + it('should throw error when invalid url given', function() { + url = new LocationUrl('http://server.org/base/abc', '/base'); - scope.$digest(); + expect(function() { + url.$$parse('http://server.org/path#/path'); + }).toThrow('Invalid url "http://server.org/path#/path", missing path prefix "/base" !'); + }); + + + describe('encoding', function() { + + it('should encode special characters', function() { + url.path('/a <>#'); + url.search({'i j': '<>#'}); + url.hash('<>#'); + + expect(url.path()).toBe('/a <>#'); + expect(url.search()).toEqual({'i j': '<>#'}); + expect(url.hash()).toBe('<>#'); + expect(url.absUrl()).toBe('http://www.domain.com:9877/a%20%3C%3E%23?i%20j=%3C%3E%23#%3C%3E%23'); + }); + + + it('should not encode !$:@', function() { + url.path('/!$:@'); + url.search(''); + url.hash('!$:@'); - expect($location).toEqualData({href: 'http://host:333/path#hash', - protocol: 'http', - host: 'host', - port: '333', - path: '/path', - search: {}, - hash: 'hash', - hashPath: 'hash', - hashSearch: {} - }); + expect(url.absUrl()).toBe('http://www.domain.com:9877/!$:@#!$:@'); + }); + + + it('should decode special characters', function() { + url = new LocationUrl('http://host.com/a%20%3C%3E%23?i%20j=%3C%3E%23#x%20%3C%3E%23'); + expect(url.path()).toBe('/a <>#'); + expect(url.search()).toEqual({'i j': '<>#'}); + expect(url.hash()).toBe('x <>#'); + }); }); }); - describe('update()', function() { + describe('HashbangUrl', function() { + + beforeEach(function() { + url = new LocationHashbangUrl('http://www.server.org:1234/base#!/path?a=b&c#hash', '!'); + }); + + + it('should parse hashband url into path and search', function() { + expect(url.protocol()).toBe('http'); + expect(url.host()).toBe('www.server.org'); + expect(url.port()).toBe(1234); + expect(url.path()).toBe('/path'); + expect(url.search()).toEqual({a: 'b', c: true}); + expect(url.hash()).toBe('hash'); + }); + + + it('absUrl() should return hashbang url', function() { + expect(url.absUrl()).toBe('http://www.server.org:1234/base#!/path?a=b&c#hash'); + + url.path('/new/path'); + url.search({one: 1}); + url.hash('hhh'); + expect(url.absUrl()).toBe('http://www.server.org:1234/base#!/new/path?one=1#hhh'); + }); + + + it('should preserve query params in base', function() { + url = new LocationHashbangUrl('http://www.server.org:1234/base?base=param#/path?a=b&c#hash', ''); + expect(url.absUrl()).toBe('http://www.server.org:1234/base?base=param#/path?a=b&c#hash'); + + url.path('/new/path'); + url.search({one: 1}); + url.hash('hhh'); + expect(url.absUrl()).toBe('http://www.server.org:1234/base?base=param#/new/path?one=1#hhh'); + }); + + + it('should prefix path with forward-slash', function() { + url = new LocationHashbangUrl('http://host.com/base#path', ''); + expect(url.path()).toBe('/path'); + expect(url.absUrl()).toBe('http://host.com/base#/path'); + + url.path('wrong'); + expect(url.path()).toBe('/wrong'); + expect(url.absUrl()).toBe('http://host.com/base#/wrong'); + }); + + + it('should set path to forward-slash when empty', function() { + url = new LocationHashbangUrl('http://server/base#!', '!'); + url.path('aaa'); + + expect(url.path()).toBe('/aaa'); + expect(url.absUrl()).toBe('http://server/base#!/aaa'); + }); - 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 not preserve old properties when parsing new url', function() { + url.$$parse('http://www.server.org:1234/base#!/'); + + expect(url.path()).toBe('/'); + expect(url.search()).toEqual({}); + expect(url.hash()).toBe(''); + expect(url.absUrl()).toBe('http://www.server.org:1234/base#!/'); }); - 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 throw error when invalid url given', function() { + expect(function() { + url.$$parse('http://server.org/path#/path'); + }).toThrow('Invalid url "http://server.org/path#/path", missing hash prefix "!" !'); }); - 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('encoding', function() { + + it('should encode special characters', function() { + url.path('/a <>#'); + url.search({'i j': '<>#'}); + url.hash('<>#'); + + expect(url.path()).toBe('/a <>#'); + expect(url.search()).toEqual({'i j': '<>#'}); + expect(url.hash()).toBe('<>#'); + expect(url.absUrl()).toBe('http://www.server.org:1234/base#!/a%20%3C%3E%23?i%20j=%3C%3E%23#%3C%3E%23'); + }); + + + it('should not encode !$:@', function() { + url.path('/!$:@'); + url.search(''); + url.hash('!$:@'); + + expect(url.absUrl()).toBe('http://www.server.org:1234/base#!/!$:@#!$:@'); + }); + + + it('should decode special characters', function() { + url = new LocationHashbangUrl('http://host.com/a#/%20%3C%3E%23?i%20j=%3C%3E%23#x%20%3C%3E%23', ''); + expect(url.path()).toBe('/ <>#'); + expect(url.search()).toEqual({'i j': '<>#'}); + expect(url.hash()).toBe('x <>#'); + }); }); }); - describe('updateHash()', function() { + var $browser, $location, scope; + + function init(url, html5Mode, basePath, hashPrefix, supportHistory) { + scope = angular.scope(null, { + $locationConfig: {html5Mode: html5Mode, hashPrefix: hashPrefix}, + $sniffer: {history: supportHistory}}); + + $browser = scope.$service('$browser'); + $browser.url(url); + $browser.$$baseHref = basePath; + $location = scope.$service('$location'); + } + + function dealocRootElement() { + dealoc(scope.$service('$document')); + } + + + describe('wiring', function() { + + beforeEach(function() { + init('http://new.com/a/b#!', false, '/a/b', '!', true); + }); + - 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 update $location when browser url changes', function() { + spyOn($location, '$$parse').andCallThrough(); + $browser.url('http://new.com/a/b#!/aaa'); + $browser.poll(); + expect($location.absUrl()).toBe('http://new.com/a/b#!/aaa'); + expect($location.path()).toBe('/aaa'); + expect($location.$$parse).toHaveBeenCalledOnce(); }); - it('should reset hashSearch when updating with a single string', function() { - $location.updateHash({foo:'bar'}); //set some initial state for hashSearch + it('should update browser when $location changes', function() { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + $location.path('/new/path'); + expect($browserUrl).not.toHaveBeenCalled(); + scope.$apply(); - $location.updateHash('path'); - expect($location.hashPath).toEqual('path'); - expect($location.hashSearch).toEqual({}); + expect($browserUrl).toHaveBeenCalledOnce(); + expect($browser.url()).toBe('http://new.com/a/b#!/new/path'); }); - 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 update browser only once per $apply cycle', function() { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + $location.path('/new/path'); + + scope.$watch(function() { + $location.search('a=b'); + }); + + scope.$apply(); + expect($browserUrl).toHaveBeenCalledOnce(); + expect($browser.url()).toBe('http://new.com/a/b#!/new/path?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 replace browser url when url was replaced at least once', function() { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + $location.path('/n/url').replace(); + scope.$apply(); + + expect($browserUrl).toHaveBeenCalledOnce(); + expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b#!/n/url', true]); }); - it('should update href and hash when updating to empty string', function() { - $location.updateHash(''); - expect($location.href).toBe('http://server'); - expect($location.hash).toBe(''); + it('should update the browser if changed from within a watcher', function() { + scope.$watch(function() { return true; }, function() { + $location.path('/changed'); + }); scope.$digest(); + expect($browser.url()).toBe('http://new.com/a/b#!/changed'); + }); + }); + + + // html5 history is disabled + describe('disabled history', function() { + + it('should use hashbang url with hash prefix', function() { + init('http://domain.com/base/index.html#!/a/b', false, '/base/index.html', '!'); + expect($browser.url()).toBe('http://domain.com/base/index.html#!/a/b'); + $location.path('/new'); + $location.search({a: true}); + scope.$apply(); + expect($browser.url()).toBe('http://domain.com/base/index.html#!/new?a'); + }); + + + it('should use hashbang url without hash prefix', function() { + init('http://domain.com/base/index.html#/a/b', false, '/base/index.html', ''); + expect($browser.url()).toBe('http://domain.com/base/index.html#/a/b'); + $location.path('/new'); + $location.search({a: true}); + scope.$apply(); + expect($browser.url()).toBe('http://domain.com/base/index.html#/new?a'); + }); + }); + + + // html5 history enabled, but not supported by browser + describe('history on old browser', function() { + + afterEach(dealocRootElement); + + it('should use hashbang url with hash prefix', function() { + init('http://domain.com/base/index.html#!!/a/b', true, '/base/index.html', '!!', false); + expect($browser.url()).toBe('http://domain.com/base/index.html#!!/a/b'); + $location.path('/new'); + $location.search({a: true}); + scope.$apply(); + expect($browser.url()).toBe('http://domain.com/base/index.html#!!/new?a'); + }); + - expect($location.href).toBe('http://server'); - expect($location.hash).toBe(''); + it('should redirect to hashbang url when new url given', function() { + init('http://domain.com/base/new-path/index.html', true, '/base/index.html', '!'); + expect($browser.url()).toBe('http://domain.com/base/index.html#!/new-path/index.html'); + }); + }); + + + // html5 history enabled and supported by browser + describe('history on new browser', function() { + + afterEach(dealocRootElement); + + it('should use new url', function() { + init('http://domain.com/base/old/index.html#a', true, '/base/index.html', '', true); + expect($browser.url()).toBe('http://domain.com/base/old/index.html#a'); + $location.path('/new'); + $location.search({a: true}); + scope.$apply(); + expect($browser.url()).toBe('http://domain.com/base/new?a#a'); + }); + + + it('should rewrite when hashbang url given', function() { + init('http://domain.com/base/index.html#!/a/b', true, '/base/index.html', '!', true); + expect($browser.url()).toBe('http://domain.com/base/a/b'); + $location.path('/new'); + $location.hash('abc'); + scope.$apply(); + expect($browser.url()).toBe('http://domain.com/base/new#abc'); + expect($location.path()).toBe('/new'); + }); + + + it('should rewrite when hashbang url given (without hash prefix)', function() { + init('http://domain.com/base/index.html#/a/b', true, '/base/index.html', '', true); + expect($browser.url()).toBe('http://domain.com/base/a/b'); + expect($location.path()).toBe('/a/b'); }); }); @@ -255,21 +467,21 @@ describe('$location', 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'); + expect(match[1]).toBe('http'); + expect(match[3]).toBe('www.angularjs.org'); + expect(match[6]).toBe('/path'); + expect(match[8]).toBe('search'); + expect(match[10]).toBe('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[1]).toBe('file'); + expect(match[3]).toBe(''); expect(match[5]).toBeFalsy(); - expect(match[6]).toEqual('/Users/Shared/misko/work/angular.js/scenario/widgets.html'); + expect(match[6]).toBe('/Users/Shared/misko/work/angular.js/scenario/widgets.html'); expect(match[8]).toBeFalsy(); }); @@ -277,30 +489,170 @@ describe('$location', function() { 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[1]).toBe('http'); + expect(match[3]).toBe('a-b1.c-d.09'); expect(match[5]).toBeFalsy(); - expect(match[6]).toEqual('/path'); + expect(match[6]).toBe('/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'); + expect(match[3]).toBe('host.org'); match = URL_MATCH.exec('http://host.org#'); - expect(match[3]).toEqual('host.org'); + expect(match[3]).toBe('host.org'); match = URL_MATCH.exec('http://host.org?'); - expect(match[3]).toEqual('host.org'); + expect(match[3]).toBe('host.org'); }); it('should match with just "/" path', function() { var match = URL_MATCH.exec('http://server/#?book=moby'); - expect(match[10]).toEqual('?book=moby'); + expect(match[10]).toBe('?book=moby'); + }); + }); + + + describe('PATH_MATCH', function() { + + it('should parse just path', function() { + var match = PATH_MATCH.exec('/path'); + expect(match[1]).toBe('/path'); + }); + + + it('should parse path with search', function() { + var match = PATH_MATCH.exec('/ppp/a?a=b&c'); + expect(match[1]).toBe('/ppp/a'); + expect(match[3]).toBe('a=b&c'); + }); + + + it('should parse path with hash', function() { + var match = PATH_MATCH.exec('/ppp/a#abc?'); + expect(match[1]).toBe('/ppp/a'); + expect(match[5]).toBe('abc?'); + }); + + + it('should parse path with both search and hash', function() { + var match = PATH_MATCH.exec('/ppp/a?a=b&c#abc/d?'); + expect(match[3]).toBe('a=b&c'); + }); + }); + + + describe('link rewriting', function() { + + var root, link, extLink, $browser, originalBrowser, lastEventPreventDefault; + + function init(linkHref, html5Mode, supportHist, attrs) { + var jqRoot = jqLite('
'); + attrs = attrs ? ' ' + attrs + ' ' : ''; + link = jqLite('link')[0]; + root = jqRoot.append(link)[0]; + + jqLite(document.body).append(jqRoot); + + var scope = angular.scope(null, { + $document: jqRoot, + $sniffer: {history: supportHist}, + $locationConfig: {html5Mode: html5Mode, hashPrefix: '!'} + }); + + $browser = scope.$service('$browser'); + $browser.url('http://host.com/base'); + $browser.$$baseHref = '/base/index.html'; + var $location = scope.$service('$location'); + originalBrowser = $browser.url(); + + // we have to prevent the default operation, as we need to test absolute links (http://...) + // and navigating to these links would kill jstd + jqRoot.bind('click', function(e) { + lastEventPreventDefault = e.isDefaultPrevented(); + e.preventDefault(); + }); + } + + function triggerAndExpectRewriteTo(url) { + browserTrigger(link, 'click'); + expect(lastEventPreventDefault).toBe(true); + expect($browser.url()).toBe(url); + } + + function triggerAndExpectNoRewrite() { + browserTrigger(link, 'click'); + expect(lastEventPreventDefault).toBe(false); + expect($browser.url()).toBe(originalBrowser); + } + + afterEach(function() { + dealoc(root); + dealoc(document.body); + }); + + + it('should rewrite rel link to new url when history enabled on new browser', function() { + init('link?a#b', true, true); + triggerAndExpectRewriteTo('http://host.com/base/link?a#b'); + }); + + + it('should rewrite abs link to new url when history enabled on new browser', function() { + init('/base/link?a#b', true, true); + triggerAndExpectRewriteTo('http://host.com/base/link?a#b'); + }); + + + it('should rewrite rel link to hashbang url when history enabled on old browser', function() { + init('link?a#b', true, false); + triggerAndExpectRewriteTo('http://host.com/base/index.html#!/link?a#b'); + }); + + + it('should rewrite abs link to hashbang url when history enabled on old browser', function() { + init('/base/link?a#b', true, false); + triggerAndExpectRewriteTo('http://host.com/base/index.html#!/link?a#b'); + }); + + + it('should not rewrite when history disabled', function() { + init('#new', false); + triggerAndExpectNoRewrite(); + }); + + + it('should not rewrite ng:ext-link', function() { + init('#new', true, true, 'ng:ext-link'); + triggerAndExpectNoRewrite(); + }); + + + it('should not rewrite full url links do different domain', function() { + init('http://www.dot.abc/a?b=c', true); + triggerAndExpectNoRewrite(); + }); + + + it('should not rewrite links with target="_blank"', function() { + init('/a?b=c', true, true, 'target="_blank"'); + triggerAndExpectNoRewrite(); + }); + + + it('should not rewrite links with target specified', function() { + init('/a?b=c', true, true, 'target="some-frame"'); + triggerAndExpectNoRewrite(); + }); + + + it('should rewrite full url links to same domain and base path', function() { + init('http://host.com/base/new', true); + triggerAndExpectRewriteTo('http://host.com/base/index.html#!/new'); }); }); }); -- cgit v1.2.3