diff --git a/Gruntfile.js b/Gruntfile.js index 4f3651ff3..98105632a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -36,6 +36,7 @@ module.exports = function (grunt) { 'src/urlRouter.js', 'src/state.js', 'src/viewDirective.js', + 'src/stateDirectives.js', 'src/compat.js' ], dest: '<%= builddir %>/<%= pkg.name %>.js' diff --git a/src/state.js b/src/state.js index f73e886e7..0adc41ea9 100644 --- a/src/state.js +++ b/src/state.js @@ -266,9 +266,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return $state.$current.includes[findState(stateOrName).name]; }; - $state.href = function (stateOrName, params) { - var state = findState(stateOrName), nav = state.navigable; - if (!nav) throw new Error("State '" + state + "' is not navigable"); + $state.href = function (stateOrName, params, options) { + options = extend({ lossy: true }, options || {}); + var state = findState(stateOrName); + var nav = options.lossy ? state.navigable : state; + + if (!nav || !nav.url) throw new Error("State '" + state + "' " + ( + options.lossy ? "does not have a URL or navigable parent" : "is not navigable" + )); return nav.url.format(normalize(state.params, params || {})); }; diff --git a/src/stateDirectives.js b/src/stateDirectives.js new file mode 100644 index 000000000..001bb0ba7 --- /dev/null +++ b/src/stateDirectives.js @@ -0,0 +1,46 @@ +function parseStateRef(ref) { + var parsed = ref.match(/^([^(]+?)\s*(\((.*)\))?$/); + if (!parsed || parsed.length !== 4) throw new Error("Invalid state ref '" + ref + "'"); + return { state: parsed[1], paramExpr: parsed[3] || null }; +} + +$StateRefDirective.$inject = ['$state']; +function $StateRefDirective($state) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + var ref = parseStateRef(attrs.uiSref); + var params = null, url = null; + var isForm = element[0].nodeName === "FORM"; + var attr = isForm ? "action" : "href", nav = true; + + var update = function(newVal) { + if (newVal) params = newVal; + if (!nav) return; + + try { + element[0][attr] = $state.href(ref.state, params, { lossy: true }); + } catch (e) { + nav = false; + } + }; + + if (ref.paramExpr) { + scope.$watch(ref.paramExpr, function(newVal, oldVal) { + if (newVal !== oldVal) update(newVal); + }, true); + params = scope.$eval(ref.paramExpr); + } + update(); + + if (isForm) return; + + element.bind("click", function(e) { + $state.transitionTo(ref.state, params); + e.preventDefault(); + }); + } + }; +} + +angular.module('ui.state').directive('uiSref', $StateRefDirective); diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js new file mode 100644 index 000000000..c229c6383 --- /dev/null +++ b/test/stateDirectivesSpec.js @@ -0,0 +1,70 @@ +describe('uiStateRef', function() { + + beforeEach(module('ui.state')); + + beforeEach(module(function($stateProvider) { + $stateProvider.state('index', { + url: '/' + }).state('contacts', { + url: '/contacts' + }).state('contacts.item', { + url: '/:id' + }).state('contacts.item.detail', {}); + })); + + describe('links', function() { + var el, scope; + + beforeEach(inject(function($rootScope, $compile) { + el = angular.element('Details'); + scope = $rootScope; + scope.contact = { id: 5 }; + scope.$apply(); + + $compile(el)(scope); + scope.$digest(); + })); + + + it('should generate the correct href', function() { + expect(el.attr('href')).toBe('/contacts/5'); + }); + + it('should update the href when parameters change', function() { + expect(el.attr('href')).toBe('/contacts/5'); + scope.contact.id = 6; + scope.$apply(); + expect(el.attr('href')).toBe('/contacts/6'); + }); + + it('should transition states when clicked', inject(function($state, $stateParams, $document, $q) { + expect($state.$current.name).toEqual(''); + + var e = $document[0].createEvent("MouseEvents"); + e.initMouseEvent("click"); + el[0].dispatchEvent(e); + + $q.flush(); + expect($state.current.name).toEqual('contacts.item.detail'); + expect($stateParams).toEqual({ id: "5" }); + })); + }); + + describe('forms', function() { + var el, scope; + + beforeEach(inject(function($rootScope, $compile) { + el = angular.element('
'); + scope = $rootScope; + scope.contact = { id: 5 }; + scope.$apply(); + + $compile(el)(scope); + scope.$digest(); + })); + + it('should generate the correct action', function() { + expect(el.attr('action')).toBe('/contacts/5'); + }); + }); +}); diff --git a/test/stateSpec.js b/test/stateSpec.js index 0a3337497..900772897 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -256,7 +256,16 @@ describe('state', function () { describe('.href()', function () { it('aborts on un-navigable states', inject(function ($state) { - expect(function() { $state.href("A"); }).toThrow("State 'A' is not navigable"); + expect(function() { $state.href("A"); }).toThrow( + "State 'A' does not have a URL or navigable parent" + ); + expect(function() { $state.href("about.sidebar", null, { lossy: false }); }).toThrow( + "State 'about.sidebar' is not navigable" + ); + })); + + it('generates a parent state URL when lossy is true', inject(function ($state) { + expect($state.href("about.sidebar", null, { lossy: true })).toEqual("/about"); })); it('generates a URL without parameters', inject(function ($state) { diff --git a/test/test-config.js b/test/test-config.js index 74e016357..82e89d8dd 100644 --- a/test/test-config.js +++ b/test/test-config.js @@ -17,8 +17,9 @@ files = [ 'src/urlRouter.js', 'src/state.js', 'src/viewDirective.js', + 'src/stateDirectives.js', 'src/compat.js', - + 'test/*Spec.js', // 'test/compat/matchers.js', // 'test/compat/*Spec.js',