Skip to content

Commit

Permalink
feat(uiState): add ui-state directive
Browse files Browse the repository at this point in the history
 - Refactor StateRefDirective for better modularity
 - Drop key restrictions on ui-sref-opts
 - Improves performance over prior implementation with no extra $eval()’s

Fixes angular-ui#395, angular-ui#900, angular-ui#1932
  • Loading branch information
nateabele authored and ExpFront committed Jun 23, 2016
1 parent 93ef6b5 commit dfb426b
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 99 deletions.
184 changes: 113 additions & 71 deletions src/stateDirectives.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,43 @@ function stateContext(el) {
}
}

function getTypeInfo(el) {
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute.
var isSvg = Object.prototype.toString.call(el.prop('href')) === '[object SVGAnimatedString]';
var isForm = el[0].nodeName === "FORM";

return {
attr: isForm ? "action" : (isSvg ? 'xlink:href' : 'href'),
isAnchor: el.prop("tagName").toUpperCase() === "A",
clickable: !isForm
};
}

function clickHook(el, $state, $timeout, type, current) {
return function(e) {
var button = e.which || e.button, target = current();

if (!(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || el.attr('target'))) {
// HACK: This is to allow ng-clicks to be processed before the transition is initiated:
var transition = $timeout(function() {
$state.go(target.state, target.params, target.options);
});
e.preventDefault();

// if the state has no URL, ignore one preventDefault from the <a> directive.
var ignorePreventDefaultCount = type.isAnchor && !target.href ? 1: 0;

e.preventDefault = function() {
if (ignorePreventDefaultCount-- <= 0) $timeout.cancel(transition);
};
}
};
}

function defaultOpts(el, $state) {
return { relative: stateContext(el) || $state.$current, inherit: true };
}

/**
* @ngdoc directive
* @name ui.router.state.directive:ui-sref
Expand All @@ -24,40 +61,40 @@ function stateContext(el) {
* @restrict A
*
* @description
* A directive that binds a link (`<a>` tag) to a state. If the state has an associated
* URL, the directive will automatically generate & update the `href` attribute via
* the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking
* the link will trigger a state transition with optional parameters.
* A directive that binds a link (`<a>` tag) to a state. If the state has an associated
* URL, the directive will automatically generate & update the `href` attribute via
* the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking
* the link will trigger a state transition with optional parameters.
*
* Also middle-clicking, right-clicking, and ctrl-clicking on the link will be
* Also middle-clicking, right-clicking, and ctrl-clicking on the link will be
* handled natively by the browser.
*
* You can also use relative state paths within ui-sref, just like the relative
* You can also use relative state paths within ui-sref, just like the relative
* paths passed to `$state.go()`. You just need to be aware that the path is relative
* to the state that the link lives in, in other words the state that loaded the
* to the state that the link lives in, in other words the state that loaded the
* template containing the link.
*
* You can specify options to pass to {@link ui.router.state.$state#go $state.go()}
* using the `ui-sref-opts` attribute. Options are restricted to `location`, `inherit`,
* and `reload`.
*
* @example
* Here's an example of how you'd use ui-sref and how it would compile. If you have the
* Here's an example of how you'd use ui-sref and how it would compile. If you have the
* following template:
* <pre>
* <a ui-sref="home">Home</a> | <a ui-sref="about">About</a> | <a ui-sref="{page: 2}">Next page</a>
*
*
* <ul>
* <li ng-repeat="contact in contacts">
* <a ui-sref="contacts.detail({ id: contact.id })">{{ contact.name }}</a>
* </li>
* </ul>
* </pre>
*
*
* Then the compiled html would be (assuming Html5Mode is off and current state is contacts):
* <pre>
* <a href="#/home" ui-sref="home">Home</a> | <a href="#/about" ui-sref="about">About</a> | <a href="#/contacts?page=2" ui-sref="{page: 2}">Next page</a>
*
*
* <ul>
* <li ng-repeat="contact in contacts">
* <a href="#/contacts/1" ui-sref="contacts.detail({ id: contact.id })">Joe</a>
Expand All @@ -78,78 +115,86 @@ function stateContext(el) {
*/
$StateRefDirective.$inject = ['$state', '$timeout'];
function $StateRefDirective($state, $timeout) {
var allowedOptions = ['location', 'inherit', 'reload', 'absolute'];

return {
restrict: 'A',
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
link: function(scope, element, attrs, uiSrefActive) {
var ref = parseStateRef(attrs.uiSref, $state.current.name);
var params = null, url = null, base = stateContext(element) || $state.$current;
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute.
var hrefKind = Object.prototype.toString.call(element.prop('href')) === '[object SVGAnimatedString]' ?
'xlink:href' : 'href';
var newHref = null, isAnchor = element.prop("tagName").toUpperCase() === "A";
var isForm = element[0].nodeName === "FORM";
var attr = isForm ? "action" : hrefKind, nav = true;

var options = { relative: base, inherit: true };
var optionsOverride = scope.$eval(attrs.uiSrefOpts) || {};

angular.forEach(allowedOptions, function(option) {
if (option in optionsOverride) {
options[option] = optionsOverride[option];
}
});
var ref = parseStateRef(attrs.uiSref, $state.current.name);
var def = { state: ref.state, href: null, nav: true, params: null };
var type = getTypeInfo(element);
var active = uiSrefActive[1] || uiSrefActive[0];

var update = function(newVal) {
if (newVal) params = angular.copy(newVal);
if (!nav) return;
def.options = extend(defaultOpts(element, $state), attrs.uiSrefOpts ? scope.$eval(attrs.uiSrefOpts) : {});

newHref = $state.href(ref.state, params, options);
var update = function(val) {
if (val) def.params = angular.copy(val);
if (!def.nav) return;

var activeDirective = uiSrefActive[1] || uiSrefActive[0];
if (activeDirective) {
activeDirective.$$addStateInfo(ref.state, params);
}
if (newHref === null) {
nav = false;
return false;
}
attrs.$set(attr, newHref);
def.href = $state.href(ref.state, def.params, def.options);
def.nav = (def.href !== null);

if (active) active.$$addStateInfo(ref.state, def.params);
if (def.nav) attrs.$set(type.attr, def.href);
};

if (ref.paramExpr) {
scope.$watch(ref.paramExpr, function(newVal, oldVal) {
if (newVal !== params) update(newVal);
}, true);
params = angular.copy(scope.$eval(ref.paramExpr));
scope.$watch(ref.paramExpr, function(val) { if (val !== def.params) update(val); }, true);
def.params = angular.copy(scope.$eval(ref.paramExpr));
}
update();

if (isForm) return;

element.bind("click", function(e) {
var button = e.which || e.button;
if ( !(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || element.attr('target')) ) {
// HACK: This is to allow ng-clicks to be processed before the transition is initiated:
var transition = $timeout(function() {
$state.go(ref.state, params, options);
});
e.preventDefault();

// if the state has no URL, ignore one preventDefault from the <a> directive.
var ignorePreventDefaultCount = isAnchor && !newHref ? 1: 0;
e.preventDefault = function() {
if (ignorePreventDefaultCount-- <= 0)
$timeout.cancel(transition);
};
}
});
if (!type.clickable) return;
element.bind("click", clickHook(element, $state, $timeout, type, function() { return def; }));
}
};
}

/**
* @ngdoc directive
* @name ui.router.state.directive:ui-state
*
* @requires ui.router.state.uiSref
*
* @restrict A
*
* @description
* Much like ui-sref, but will accept named $scope properties to evaluate for a state definition,
* params and override options.
*
* @param {string} ui-state 'stateName' can be any valid absolute or relative state
* @param {Object} ui-state-params params to pass to {@link ui.router.state.$state#href $state.href()}
* @param {Object} ui-state-opts options to pass to {@link ui.router.state.$state#go $state.go()}
*/
$StateRefDynamicDirective.$inject = ['$state', '$timeout'];
function $StateRefDynamicDirective($state, $timeout) {
return {
restrict: 'A',
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
link: function(scope, element, attrs, uiSrefActive) {
var type = getTypeInfo(element);
var active = uiSrefActive[1] || uiSrefActive[0];
var group = [attrs.uiState, attrs.uiStateParams || null, attrs.uiStateOpts || null];
var watch = '[' + group.map(function(val) { return val || 'null'; }).join(', ') + ']';
var def = { state: null, params: null, options: null, href: null };

function runStateRefLink (group) {
def.state = group[0]; def.params = group[1]; def.options = group[2];
def.href = $state.href(def.state, def.params, def.options);

if (active) active.$$addStateInfo(ref.state, def.params);
if (def.href) attrs.$set(type.attr, def.href);
}

scope.$watch(watch, runStateRefLink, true);
runStateRefLink(scope.$eval(watch));

if (!type.clickable) return;
element.bind("click", clickHook(element, $state, $timeout, type, function() { return def; }));
}
};
}


/**
* @ngdoc directive
* @name ui.router.state.directive:ui-sref-active
Expand Down Expand Up @@ -270,18 +315,15 @@ function $StateRefActiveDirective($state, $stateParams, $interpolate) {
}

function addClass(el, className) { $timeout(function () { el.addClass(className); }); }

function removeClass(el, className) { el.removeClass(className); }

function anyMatch(state, params) { return $state.includes(state.name, params); }

function exactMatch(state, params) { return $state.is(state.name, params); }

}]
};
}

angular.module('ui.router.state')
.directive('uiSref', $StateRefDirective)
.directive('uiSrefActive', $StateRefActiveDirective)
.directive('uiSrefActiveEq', $StateRefActiveDirective);
.directive('uiSrefActiveEq', $StateRefActiveDirective)
.directive('uiState', $StateRefDynamicDirective);
92 changes: 65 additions & 27 deletions test/stateDirectivesSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,71 @@ describe('uiStateRef', function() {
}));
});

describe('links with dynamic state definitions', function () {
var template;

beforeEach(inject(function($rootScope, $compile, $state) {
el = angular.element('<a ui-state="state" ui-state-params="params">state</a>');
scope = $rootScope;
angular.extend(scope, { state: 'contacts', params: {} });
template = $compile(el)(scope);
scope.$digest();
}));

it('sets the correct initial href', function () {
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
});

it('updates to the new href', function () {
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');

scope.state = 'contacts.item';
scope.params = { id: 5 };
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/5');

scope.params.id = 25;
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/25');
});

it('retains the old href if the new points to a non-state', function () {
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
scope.state = 'nostate';
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
});

it('accepts param overrides', inject(function ($compile) {
el = angular.element('<a ui-state="state" ui-state-params="params">state</a>');
scope.state = 'contacts.item';
scope.params = { id: 10 };
template = $compile(el)(scope);
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/10');
}));

it('accepts option overrides', inject(function ($compile, $timeout, $state) {
var transitionOptions;

el = angular.element('<a ui-state="state" ui-state-opts="opts">state</a>');
scope.state = 'contacts';
scope.opts = { reload: true };
template = $compile(el)(scope);
scope.$digest();

spyOn($state, 'go').andCallFake(function(state, params, options) {
transitionOptions = options;
});

triggerClick(template)
$timeout.flush();

expect(transitionOptions.reload).toEqual(true);
expect(transitionOptions.absolute).toBeUndefined();
}));
});

describe('forms', function() {
var el, scope;

Expand Down Expand Up @@ -362,33 +427,6 @@ describe('uiStateRef', function() {
expect($state.$current.name).toBe("contacts");
}));
});

describe('transition options', function() {

beforeEach(inject(function($rootScope, $compile, $state) {
el = angular.element('<a ui-sref="contacts.item.detail({ id: contact.id })" ui-sref-opts="{ reload: true, absolute: true, notify: true }">Details</a>');
scope = $rootScope;
scope.contact = { id: 5 };

$compile(el)(scope);
scope.$digest();
}));

it('uses allowed transition options', inject(function($q, $timeout, $state) {
var transitionOptions;

spyOn($state, 'go').andCallFake(function(state, params, options) {
transitionOptions = options;
});

triggerClick(el);
$timeout.flush();

expect(transitionOptions.reload).toEqual(true);
expect(transitionOptions.absolute).toEqual(true);
expect(transitionOptions.notify).toBeUndefined();
}));
});
});

describe('uiSrefActive', function() {
Expand Down
2 changes: 1 addition & 1 deletion test/stateSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ describe('state', function () {
$state.transitionTo('dynamicController', { type: "Acme" });
$q.flush();
expect(ctrlName).toEqual("AcmeFooController");
}));+
}));

it('uses the templateProvider to get template dynamically', inject(function ($state, $q) {
$state.transitionTo('dynamicTemplate', { type: "Acme" });
Expand Down

0 comments on commit dfb426b

Please sign in to comment.