diff options
| author | Vojta Jina | 2011-06-22 19:57:22 +0200 | 
|---|---|---|
| committer | Vojta Jina | 2011-09-08 20:36:33 +0200 | 
| commit | 988ed451b508b9d7ea4690b150993ec62d8a3743 (patch) | |
| tree | 70c8a2200ae4b80da04b6eba239a07962f209638 | |
| parent | fc2f188d4d8f06aab31979b293d95580e19cbdf1 (diff) | |
| download | angular.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.js | 3 | ||||
| -rw-r--r-- | src/Browser.js | 130 | ||||
| -rw-r--r-- | src/angular-mocks.js | 23 | ||||
| -rw-r--r-- | src/service/location.js | 6 | ||||
| -rw-r--r-- | test/BrowserSpecs.js | 230 | ||||
| -rw-r--r-- | test/ScenarioSpec.js | 2 | ||||
| -rw-r--r-- | test/service/locationSpec.js | 10 | ||||
| -rw-r--r-- | test/widgetsSpec.js | 2 | 
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'}); | 
