aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorVojta Jina2011-06-22 19:57:22 +0200
committerVojta Jina2011-09-08 20:36:33 +0200
commit988ed451b508b9d7ea4690b150993ec62d8a3743 (patch)
tree70c8a2200ae4b80da04b6eba239a07962f209638
parentfc2f188d4d8f06aab31979b293d95580e19cbdf1 (diff)
downloadangular.js-988ed451b508b9d7ea4690b150993ec62d8a3743.tar.bz2
feat($browser): jQuery style url method, onUrlChange event
This is just basic implementation of $browser.url, $browser.onUrlChange methods: $browser.url() - returns current location.href $browser.url('/new') - set url to /new If supported, history.pushState is used, location.href property otherwise. $browser.url('/new', true) - replace current url with /new If supported, history.replaceState is used, location.replace otherwise. $browser.onUrlChange is only fired when url is changed from the browser: - user types into address bar - user clicks on back/forward button - user clicks on link It's not fired when url is changed using $browser.url() Breaks Removed $browser.setUrl(), $browser.getUrl(), use $browser.url() Breaks Removed $browser.onHashChange(), use $browser.onUrlChange()
-rw-r--r--src/AngularPublic.js3
-rw-r--r--src/Browser.js130
-rw-r--r--src/angular-mocks.js23
-rw-r--r--src/service/location.js6
-rw-r--r--test/BrowserSpecs.js230
-rw-r--r--test/ScenarioSpec.js2
-rw-r--r--test/service/locationSpec.js10
-rw-r--r--test/widgetsSpec.js2
8 files changed, 262 insertions, 144 deletions
diff --git a/src/AngularPublic.js b/src/AngularPublic.js
index 476de3e3..f63948d8 100644
--- a/src/AngularPublic.js
+++ b/src/AngularPublic.js
@@ -4,8 +4,9 @@ var browserSingleton;
angularService('$browser', function($log){
if (!browserSingleton) {
+ // TODO(vojta): inject $sniffer service when implemented
browserSingleton = new Browser(window, jqLite(window.document), jqLite(window.document.body),
- XHR, $log);
+ XHR, $log, {});
browserSingleton.bind();
}
return browserSingleton;
diff --git a/src/Browser.js b/src/Browser.js
index f25e8a20..8e8f511a 100644
--- a/src/Browser.js
+++ b/src/Browser.js
@@ -34,15 +34,16 @@ var XHR = window.XMLHttpRequest || function () {
* @param {object} body jQuery wrapped document.body.
* @param {function()} XHR XMLHttpRequest constructor.
* @param {object} $log console.log or an object with the same interface.
+ * @param {object} $sniffer $sniffer service
*/
-function Browser(window, document, body, XHR, $log) {
+function Browser(window, document, body, XHR, $log, $sniffer) {
var self = this,
rawDocument = document[0],
location = window.location,
+ history = window.history,
setTimeout = window.setTimeout,
clearTimeout = window.clearTimeout,
- pendingDeferIds = {},
- lastLocationUrl;
+ pendingDeferIds = {};
self.isMock = false;
@@ -194,78 +195,103 @@ function Browser(window, document, body, XHR, $log) {
// URL API
//////////////////////////////////////////////////////////////
- /**
- * @workInProgress
- * @ngdoc method
- * @name angular.service.$browser#setUrl
- * @methodOf angular.service.$browser
- *
- * @param {string} url New url
- *
- * @description
- * Sets browser's url
- */
- self.setUrl = function(url) {
-
- var existingURL = lastLocationUrl;
- if (!existingURL.match(/#/)) existingURL += '#';
- if (!url.match(/#/)) url += '#';
- if (existingURL != url) {
- location.href = url;
- }
- };
+ var lastBrowserUrl = location.href;
/**
* @workInProgress
* @ngdoc method
- * @name angular.service.$browser#getUrl
+ * @name angular.service.$browser#url
* @methodOf angular.service.$browser
*
* @description
- * Get current browser's url
+ * GETTER:
+ * Without any argument, this method just returns current value of location.href.
+ *
+ * SETTER:
+ * With at least one argument, this method sets url to new value.
+ * If html5 history api supported, pushState/replaceState is used, otherwise
+ * location.href/location.replace is used.
+ * Returns its own instance to allow chaining
*
- * @returns {string} Browser's url
+ * NOTE: this api is intended for use only by the $location service. Please use the
+ * {@link angular.service.$location $location service} to change url.
+ *
+ * @param {string} url New url (when used as setter)
+ * @param {boolean=} replace Should new url replace current history record ?
*/
- self.getUrl = function() {
- return lastLocationUrl = location.href;
+ self.url = function(url, replace) {
+ // setter
+ if (url) {
+ lastBrowserUrl = url;
+ if ($sniffer.history) {
+ if (replace) history.replaceState(null, '', url);
+ else history.pushState(null, '', url);
+ } else {
+ if (replace) location.replace(url);
+ else location.href = url;
+ }
+ return self;
+ // getter
+ } else {
+ return location.href;
+ }
};
+ var urlChangeListeners = [],
+ urlChangeInit = false;
+
+ function fireUrlChange() {
+ if (lastBrowserUrl == self.url()) return;
+
+ lastBrowserUrl = self.url();
+ forEach(urlChangeListeners, function(listener) {
+ listener(self.url());
+ });
+ }
/**
* @workInProgress
* @ngdoc method
- * @name angular.service.$browser#onHashChange
+ * @name angular.service.$browser#onUrlChange
* @methodOf angular.service.$browser
+ * @TODO(vojta): refactor to use node's syntax for events
*
* @description
- * Detects if browser support onhashchange events and register a listener otherwise registers
- * $browser poller. The `listener` will then get called when the hash changes.
+ * Register callback function that will be called, when url changes.
+ *
+ * It's only called when the url is changed by outside of angular:
+ * - user types different url into address bar
+ * - user clicks on history (forward/back) button
+ * - user clicks on a link
*
- * The listener gets called with either HashChangeEvent object or simple object that also contains
- * `oldURL` and `newURL` properties.
+ * It's not called when url is changed by $browser.url() method
*
- * Note: this api is intended for use only by the $location service. Please use the
- * {@link angular.service.$location $location service} to monitor hash changes in angular apps.
+ * The listener gets called with new url as parameter.
*
- * @param {function(event)} listener Listener function to be called when url hash changes.
- * @return {function()} Returns the registered listener fn - handy if the fn is anonymous.
+ * NOTE: this api is intended for use only by the $location service. Please use the
+ * {@link angular.service.$location $location service} to monitor url changes in angular apps.
+ *
+ * @param {function(string)} listener Listener function to be called when url changes.
+ * @return {function(string)} Returns the registered listener fn - handy if the fn is anonymous.
*/
- self.onHashChange = function(listener) {
- // IE8 comp mode returns true, but doesn't support hashchange event
- var dm = window.document.documentMode;
- if ('onhashchange' in window && (isUndefined(dm) || dm >= 8)) {
- jqLite(window).bind('hashchange', listener);
- } else {
- var lastBrowserUrl = self.getUrl();
-
- self.addPollFn(function() {
- if (lastBrowserUrl != self.getUrl()) {
- listener();
- lastBrowserUrl = self.getUrl();
- }
- });
+ self.onUrlChange = function(callback) {
+ if (!urlChangeInit) {
+ // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera)
+ // don't fire popstate when user change the address bar and don't fire hashchange when url
+ // changed by push/replaceState
+
+ // html5 history api - popstate event
+ if ($sniffer.history) jqLite(window).bind('popstate', fireUrlChange);
+ // hashchange event
+ if ($sniffer.hashchange) jqLite(window).bind('hashchange', fireUrlChange);
+ // polling
+ else self.addPollFn(fireUrlChange);
+
+ urlChangeInit = true;
}
- return listener;
+
+ urlChangeListeners.push(callback);
+ return callback;
};
//////////////////////////////////////////////////////////////
diff --git a/src/angular-mocks.js b/src/angular-mocks.js
index 066ccea5..8410dc8c 100644
--- a/src/angular-mocks.js
+++ b/src/angular-mocks.js
@@ -89,19 +89,19 @@ function MockBrowser() {
requests = [];
this.isMock = true;
- self.url = "http://server";
- self.lastUrl = self.url; // used by url polling fn
+ self.$$url = "http://server";
+ self.$$lastUrl = self.$$url; // used by url polling fn
self.pollFns = [];
// register url polling fn
- self.onHashChange = function(listener) {
+ self.onUrlChange = function(listener) {
self.pollFns.push(
function() {
- if (self.lastUrl != self.url) {
- self.lastUrl = self.url;
- listener();
+ if (self.$$lastUrl != self.$$url) {
+ self.$$lastUrl = self.$$url;
+ listener(self.$$url);
}
}
);
@@ -326,12 +326,13 @@ MockBrowser.prototype = {
hover: function(onHover) {
},
- getUrl: function(){
- return this.url;
- },
+ url: function(url, replace) {
+ if (url) {
+ this.$$url = url;
+ return this;
+ }
- setUrl: function(url){
- this.url = url;
+ return this.$$url;
},
cookies: function(name, value) {
diff --git a/src/service/location.js b/src/service/location.js
index 6e646b8b..2f53f520 100644
--- a/src/service/location.js
+++ b/src/service/location.js
@@ -72,8 +72,8 @@ angularServiceInject("$location", function($browser) {
var location = {update: update, updateHash: updateHash};
var lastLocation = {}; // last state since last update().
- $browser.onHashChange(bind(this, this.$apply, function() { //register
- update($browser.getUrl());
+ $browser.onUrlChange(bind(this, this.$apply, function() { //register
+ update($browser.url());
}))(); //initialize
this.$watch(sync);
@@ -120,7 +120,7 @@ angularServiceInject("$location", function($browser) {
location.href = composeHref(location);
}
- $browser.setUrl(location.href);
+ $browser.url(location.href);
copy(location, lastLocation);
}
diff --git a/test/BrowserSpecs.js b/test/BrowserSpecs.js
index 5cc7ae65..e503a17d 100644
--- a/test/BrowserSpecs.js
+++ b/test/BrowserSpecs.js
@@ -2,7 +2,7 @@
describe('browser', function(){
- var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue;
+ var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue, sniffer;
function fakeSetTimeout(fn) {
return setTimeoutQueue.push(fn) - 1; //return position in the queue
@@ -26,8 +26,32 @@ describe('browser', function(){
scripts = [];
removedScripts = [];
xhr = null;
+ sniffer = {history: true, hashchange: true};
+
+ // mock window, extract ?
fakeWindow = {
- location: {href:"http://server"},
+ events: {},
+ fire: function(name) {
+ forEach(this.events[name], function(listener) {
+ listener.apply(null, arguments);
+ });
+ },
+ addEventListener: function(name, listener) {
+ if (isUndefined(this.events[name])) {
+ this.events[name] = [];
+ }
+ this.events[name].push(listener);
+ },
+ attachEvent: function(name, listener) {
+ if (isUndefined(this.events[name])) {
+ this.events[name] = [];
+ }
+ this.events[name].push(listener);
+ },
+ removeEventListener: noop,
+ detachEvent: noop,
+ location: {href: 'http://server', replace: noop},
+ history: {replaceState: noop, pushState: noop},
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout
};
@@ -59,7 +83,7 @@ describe('browser', function(){
error: function() { logs.error.push(slice.call(arguments)); }};
browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr,
- fakeLog);
+ fakeLog, sniffer);
});
it('should contain cookie cruncher', function() {
@@ -482,96 +506,162 @@ describe('browser', function(){
});
});
+ describe('url', function() {
+ var pushState, replaceState, locationReplace;
- describe('url api', function() {
- it('should use $browser poller to detect url changes when onhashchange event is unsupported',
- function() {
+ beforeEach(function() {
+ pushState = spyOn(fakeWindow.history, 'pushState');
+ replaceState = spyOn(fakeWindow.history, 'replaceState');
+ locationReplace = spyOn(fakeWindow.location, 'replace');
+ });
- fakeWindow = {
- location: {href:"http://server"},
- document: {},
- setTimeout: fakeSetTimeout
- };
+ it('should return current location.href', function() {
+ fakeWindow.location.href = 'http://test.com';
+ expect(browser.url()).toEqual('http://test.com');
- browser = new Browser(fakeWindow, {}, {});
- browser.startPoller = function() {};
+ fakeWindow.location.href = 'https://another.com';
+ expect(browser.url()).toEqual('https://another.com');
+ });
- var events = [];
+ it('should use history.pushState when available', function() {
+ sniffer.history = true;
+ browser.url('http://new.org');
- browser.onHashChange(function() {
- events.push('x');
- });
+ expect(pushState).toHaveBeenCalled();
+ expect(pushState.argsForCall[0][2]).toEqual('http://new.org');
- fakeWindow.location.href = "http://server/#newHash";
- expect(events).toEqual([]);
- fakeSetTimeout.flush();
- expect(events).toEqual(['x']);
+ expect(replaceState).not.toHaveBeenCalled();
+ expect(locationReplace).not.toHaveBeenCalled();
+ expect(fakeWindow.location.href).toEqual('http://server');
+ });
- //don't do anything if url hasn't changed
- events = [];
- fakeSetTimeout.flush();
- expect(events).toEqual([]);
+ it('should use history.replaceState when available', function() {
+ sniffer.history = true;
+ browser.url('http://new.org', true);
+
+ expect(replaceState).toHaveBeenCalled();
+ expect(replaceState.argsForCall[0][2]).toEqual('http://new.org');
+
+ expect(pushState).not.toHaveBeenCalled();
+ expect(locationReplace).not.toHaveBeenCalled();
+ expect(fakeWindow.location.href).toEqual('http://server');
});
+ it('should set location.href when pushState not available', function() {
+ sniffer.history = false;
+ browser.url('http://new.org');
- it('should use onhashchange events to detect url changes when supported by browser',
- function() {
+ expect(fakeWindow.location.href).toEqual('http://new.org');
- var onHashChngListener;
+ expect(pushState).not.toHaveBeenCalled();
+ expect(replaceState).not.toHaveBeenCalled();
+ expect(locationReplace).not.toHaveBeenCalled();
+ });
- fakeWindow = {location: {href:"http://server"},
- addEventListener: function(type, listener) {
- expect(type).toEqual('hashchange');
- onHashChngListener = listener;
- },
- attachEvent: function(type, listener) {
- expect(type).toEqual('onhashchange');
- onHashChngListener = listener;
- },
- removeEventListener: angular.noop,
- detachEvent: angular.noop,
- document: {}
- };
- fakeWindow.onhashchange = true;
+ it('should use location.replace when history.replaceState not available', function() {
+ sniffer.history = false;
+ browser.url('http://new.org', true);
- browser = new Browser(fakeWindow, {}, {});
+ expect(locationReplace).toHaveBeenCalledWith('http://new.org');
- var events = [],
- event = {type: "hashchange"};
+ expect(pushState).not.toHaveBeenCalled();
+ expect(replaceState).not.toHaveBeenCalled();
+ expect(fakeWindow.location.href).toEqual('http://server');
+ });
- browser.onHashChange(function(e) {
- events.push(e);
- });
+ it('should return $browser to allow chaining', function() {
+ expect(browser.url('http://any.com')).toBe(browser);
+ });
+ });
- expect(events).toEqual([]);
- onHashChngListener(event);
+ describe('urlChange', function() {
+ var callback;
- expect(events.length).toBe(1);
- expect(events[0].originalEvent || events[0]).toBe(event); // please jQuery and jqLite
+ beforeEach(function() {
+ callback = jasmine.createSpy('onUrlChange');
+ });
- // clean up the jqLite cache so that the global afterEach doesn't complain
- if (!jQuery) {
- jqLite(fakeWindow).dealoc();
- }
+ afterEach(function() {
+ if (!jQuery) jqLite(fakeWindow).dealoc();
});
- // asynchronous test
- it('should fire onHashChange when location.hash change', function() {
- var callback = jasmine.createSpy('onHashChange');
- browser = new Browser(window, {}, {});
- browser.onHashChange(callback);
+ it('should return registered callback', function() {
+ expect(browser.onUrlChange(callback)).toBe(callback);
+ });
- window.location.hash = 'new-hash';
- browser.addPollFn(function() {});
+ it('should forward popstate event with new url when history supported', function() {
+ sniffer.history = true;
+ browser.onUrlChange(callback);
+ fakeWindow.location.href = 'http://server/new';
- waitsFor(function() {
- return callback.callCount;
- }, 'onHashChange callback to be called', 1000);
+ fakeWindow.fire('popstate');
+ expect(callback).toHaveBeenCalledWith('http://server/new');
- runs(function() {
- if (!jQuery) jqLite(window).dealoc();
- window.location.hash = '';
- });
+ fakeWindow.fire('hashchange');
+ fakeSetTimeout.flush();
+ expect(callback.callCount).toBe(1);
+ });
+
+ it('should forward only popstate event when both history and hashchange supported', function() {
+ sniffer.history = true;
+ sniffer.hashchange = true;
+ browser.onUrlChange(callback);
+ fakeWindow.location.href = 'http://server/new';
+
+ fakeWindow.fire('popstate');
+ expect(callback).toHaveBeenCalledWith('http://server/new');
+
+ fakeWindow.fire('hashchange');
+ fakeSetTimeout.flush();
+ expect(callback.callCount).toBe(1);
+ });
+
+ it('should forward hashchange event with new url when only hashchange supported', function() {
+ sniffer.history = false;
+ sniffer.hashchange = true;
+ browser.onUrlChange(callback);
+ fakeWindow.location.href = 'http://server/new';
+
+ fakeWindow.fire('hashchange');
+ expect(callback).toHaveBeenCalledWith('http://server/new');
+
+ fakeWindow.fire('popstate');
+ fakeSetTimeout.flush();
+ expect(callback.callCount).toBe(1);
+ });
+
+ it('should use polling when neither history nor hashchange supported', function() {
+ sniffer.history = false;
+ sniffer.hashchange = false;
+ browser.onUrlChange(callback);
+
+ fakeWindow.location.href = 'http://server.new';
+ fakeSetTimeout.flush();
+ expect(callback).toHaveBeenCalledWith('http://server.new');
+
+ fakeWindow.fire('popstate');
+ fakeWindow.fire('hashchange');
+ expect(callback.callCount).toBe(1);
+ });
+
+ it('should not fire urlChange if changed by browser.url method (polling)', function() {
+ sniffer.history = false;
+ sniffer.hashchange = false;
+ browser.onUrlChange(callback);
+ browser.url('http://new.com');
+
+ fakeSetTimeout.flush();
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('should not fire urlChange if changed by browser.url method (hashchange)', function() {
+ sniffer.history = false;
+ sniffer.hashchange = true;
+ browser.onUrlChange(callback);
+ browser.url('http://new.com');
+
+ fakeWindow.fire('hashchange');
+ expect(callback).not.toHaveBeenCalled();
});
});
diff --git a/test/ScenarioSpec.js b/test/ScenarioSpec.js
index e91e0b98..54c99f77 100644
--- a/test/ScenarioSpec.js
+++ b/test/ScenarioSpec.js
@@ -40,7 +40,7 @@ describe("ScenarioSpec: Compilation", function(){
var $location = scope.$service('$location');
var $browser = scope.$service('$browser');
expect($location.hashSearch.book).toBeUndefined();
- $browser.setUrl(url);
+ $browser.url(url);
$browser.poll();
expect($location.hashSearch.book).toEqual('moby');
});
diff --git a/test/service/locationSpec.js b/test/service/locationSpec.js
index 5839b0b6..a137149d 100644
--- a/test/service/locationSpec.js
+++ b/test/service/locationSpec.js
@@ -32,24 +32,24 @@ describe('$location', function() {
it('should update location when browser url changed', function() {
var origUrl = $location.href;
- expect(origUrl).toEqual($browser.getUrl());
+ expect(origUrl).toEqual($browser.url());
var newUrl = 'http://somenew/url#foo';
- $browser.setUrl(newUrl);
+ $browser.url(newUrl);
$browser.poll();
expect($location.href).toEqual(newUrl);
});
it('should update browser at the end of $eval', function() {
- var origBrowserUrl = $browser.getUrl();
+ 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.getUrl()).toEqual('http://www.angularjs.org/a/b');
+ expect($browser.url()).toEqual('http://www.angularjs.org/a/b');
$location.path = '/c';
scope.$digest();
- expect($browser.getUrl()).toEqual('http://www.angularjs.org/c');
+ expect($browser.url()).toEqual('http://www.angularjs.org/c');
});
diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js
index 42220201..dd23ed77 100644
--- a/test/widgetsSpec.js
+++ b/test/widgetsSpec.js
@@ -1183,7 +1183,7 @@ describe("widget", function(){
var myApp = angular.scope();
var $browser = myApp.$service('$browser');
$browser.xhr.expectGET('includePartial.html').respond('view: <ng:view></ng:view>');
- $browser.setUrl('http://server/#/foo');
+ $browser.url('http://server/#/foo');
var $route = myApp.$service('$route');
$route.when('/foo', {controller: angular.noop, template: 'viewPartial.html'});