Skip to content

Commit

Permalink
fix(ui-sref): Update ui-sref/state href when states are added/removed
Browse files Browse the repository at this point in the history
fix(ui-state): Support one time bindings in ng 1.3

Closes #3131
Closes #3054
  • Loading branch information
christopherthielen committed Nov 5, 2016
1 parent 6f22898 commit 389dfd5
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 40 deletions.
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function karmaServedFiles(ngVersion) {
var webpackConfig = require('./webpack.config.js');
webpackConfig.entry = {};
webpackConfig.plugins = [];
webpackConfig.devtool = 'inline-source-map';

module.exports = function(config) {
var ngVersion = config.ngversion || "1.5.0";
Expand Down
103 changes: 65 additions & 38 deletions src/ng1/directives/stateDirectives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ng as angular } from "../../angular";
import { IAugmentedJQuery, ITimeoutService, IScope, IInterpolateService } from "angular";

import {
Obj, extend, forEach, toJson, tail, isString, isObject, parse,
Obj, extend, forEach, toJson, tail, isString, isObject, parse, noop,
PathNode, StateOrName, StateService, TransitionService, State, UIRouter
} from "ui-router-core";
import { UIViewData } from "./viewDirective";
Expand Down Expand Up @@ -50,14 +50,14 @@ function getTypeInfo(el: IAugmentedJQuery): TypeInfo {
}

/** @hidden */
function clickHook(el: IAugmentedJQuery, $state: StateService, $timeout: ITimeoutService, type: TypeInfo, current: Function) {
function clickHook(el: IAugmentedJQuery, $state: StateService, $timeout: ITimeoutService, type: TypeInfo, getDef: () => Def) {
return function (e: JQueryMouseEventObject) {
var button = e.which || e.button, target = current();
var button = e.which || e.button, target = getDef();

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);
$state.go(target.uiState, target.uiStateParams, target.uiStateOpts);
});
e.preventDefault();

Expand Down Expand Up @@ -142,38 +142,42 @@ function defaultOpts(el: IAugmentedJQuery, $state: StateService) {
* @param {string} ui-sref 'stateName' can be any valid absolute or relative state
* @param {Object} ui-sref-opts options to pass to [[StateService.go]]
*/
let uiSref = ['$state', '$timeout',
function $StateRefDirective($state: StateService, $timeout: ITimeoutService) {
let uiSref = ['$uiRouter', '$timeout',
function $StateRefDirective($uiRouter: UIRouter, $timeout: ITimeoutService) {
let $state = $uiRouter.stateService;

return {
restrict: 'A',
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
link: function (scope: IScope, element: IAugmentedJQuery, attrs: any, uiSrefActive: any) {
var ref = parseStateRef(attrs.uiSref, $state.current.name);
var def: Def = { state: ref.state, href: null, params: null, options: null };
var def = { uiState: ref.state } as Def;
var type = getTypeInfo(element);
var active = uiSrefActive[1] || uiSrefActive[0];
var unlinkInfoFn: Function = null;
var hookFn;

def.options = extend(defaultOpts(element, $state), attrs.uiSrefOpts ? scope.$eval(attrs.uiSrefOpts) : {});
def.uiStateOpts = extend(defaultOpts(element, $state), attrs.uiSrefOpts ? scope.$eval(attrs.uiSrefOpts) : {});

var update = function (val?: any) {
if (val) def.params = angular.copy(val);
def.href = $state.href(ref.state, def.params, def.options);
if (val) def.uiStateParams = angular.copy(val);
def.href = $state.href(ref.state, def.uiStateParams, def.uiStateOpts);

if (unlinkInfoFn) unlinkInfoFn();
if (active) unlinkInfoFn = active.$$addStateInfo(ref.state, def.params);
if (active) unlinkInfoFn = active.$$addStateInfo(ref.state, def.uiStateParams);
if (def.href !== null) attrs.$set(type.attr, def.href);
};

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

scope.$on('$destroy', <any> $uiRouter.stateRegistry.onStatesChanged(() => update()));

if (!type.clickable) return;
hookFn = clickHook(element, $state, $timeout, type, function () {
return def;
Expand All @@ -187,52 +191,75 @@ let uiSref = ['$state', '$timeout',
}];

/**
* `ui-state`: A dynamic version of `ui-sref`
* `ui-state`: A dynamic version of the `ui-sref` directive
*
* Much like ui-sref, but it `$observe`s inputs and `$watch`es/evaluates values.
*
* The `ui-sref` directive takes a string literal, which is split into 1) state name and 2) parameter values expression.
* It does not `$observe` the input string and `$watch`es only the parameter value expression.
* Because of this, `ui-sref` is fairly lightweight, but does no deal well with with srefs that dynamically change.
*
*
* On the other hand, the `ui-state` directive is fully dynamic.
* It is useful for building dynamic links, such as data–driven navigation links.
*
* It consists of three attributes:
*
* - `ui-state="expr"`: The state to link to; the `expr` string is evaluated and `$watch`ed
* - `ui-state-params="expr"`: The state params to link to; the `expr` string is evaluated and `$watch`ed
* - `ui-state-opts="expr"`: The transition options for the link; the `expr` string is evaluated and `$watch`ed
*
* Much like ui-sref, but will accept named $scope properties to evaluate for a state definition,
* params and override options.
* In angular 1.3 and above, a one time binding may be used if you know specific bindings will not change, i.e:
* `ui-params="::foo.params"`.
*
* Like `ui-sref`, this directive also works with `ui-sref-active` and `ui-sref-active-eq`.
*
* @example
* ```html
*
* <li ng-repeat="nav in navlinks">
* <a ui-state="nav.statename">{{nav.description}}</a>
* <li ng-repeat="nav in navlinks" ui-sref-active="active">
* <a ui-state="nav.statename" ui-state-params="nav.params">{{nav.description}}</a>
* </li>
* ```
*
* @param {string} ui-state 'stateName' can be any valid absolute or relative state
* @param {Object} ui-state-params params to pass to [[StateService.href]]
* @param {Object} ui-state-opts options to pass to [[StateService.go]]
*/
let uiState = ['$state', '$timeout',
function $StateRefDynamicDirective($state: StateService, $timeout: ITimeoutService) {
let uiState = ['$uiRouter', '$timeout',
function $StateRefDynamicDirective($uiRouter: UIRouter, $timeout: ITimeoutService) {
let $state = $uiRouter.stateService;

return {
restrict: 'A',
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
link: function (scope: IScope, element: IAugmentedJQuery, attrs: any, uiSrefActive: any) {
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: Def = { state: null, params: null, options: null, href: null };
var def = {} as Def;
let inputAttrs = ['uiState', 'uiStateParams', 'uiStateOpts'];
let watchDeregFns = inputAttrs.reduce((acc, attr) => (acc[attr] = noop, acc), {});
var unlinkInfoFn: Function = null;
var hookFn;

function runStateRefLink(group: any[]) {
def.state = group[0];
def.params = group[1];
def.options = group[2];
def.href = $state.href(def.state, def.params, def.options);
function update() {
def.href = $state.href(def.uiState, def.uiStateParams, def.uiStateOpts);

if (unlinkInfoFn) unlinkInfoFn();
if (active) unlinkInfoFn = active.$$addStateInfo(def.state, def.params);
if (active) unlinkInfoFn = active.$$addStateInfo(def.uiState, def.uiStateParams);
if (def.href) attrs.$set(type.attr, def.href);
}

scope.$watch(watch, runStateRefLink, true);
runStateRefLink(scope.$eval(watch));
inputAttrs.forEach((field) => {
def[field] = attrs[field] ? scope.$eval(attrs[field]) : null;

attrs.$observe(field, (expr) => {
watchDeregFns[field]();
watchDeregFns[field] = scope.$watch(expr, (newval) => {
def[field] = newval;
update();
}, true);
})
});

scope.$on('$destroy', <any> $uiRouter.stateRegistry.onStatesChanged(() => update()));
update();

if (!type.clickable) return;
hookFn = clickHook(element, $state, $timeout, type, function () {
Expand Down Expand Up @@ -456,7 +483,7 @@ let uiSrefActive = ['$state', '$stateParams', '$interpolate', '$transitions', '$
};
}];

interface Def { state: string; href: string; params: Obj; options: any;
interface Def { uiState: string; href: string; uiStateParams: Obj; uiStateOpts: any;
}
angular.module('ui.router.state')
.directive('uiSref', uiSref)
Expand Down
58 changes: 56 additions & 2 deletions test/stateDirectivesSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ describe('uiStateRef', function() {
_locationProvider = $locationProvider;
$stateProvider.state('top', {
url: ''
}).state('other', {
url: '/other/:id',
template: 'other'
}).state('contacts', {
url: '/contacts',
template: '<a ui-sref=".item({ id: 5 })" class="item">Person</a> <ui-view></ui-view>'
Expand Down Expand Up @@ -365,6 +368,14 @@ describe('uiStateRef', function() {

}));

it('updates to a new href when it points to a new state', function () {
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
scope.state = 'other';
scope.params = { id: '123' };
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/other/123');
});

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';
Expand All @@ -373,14 +384,57 @@ describe('uiStateRef', function() {
});

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 };
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/10');
}));

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

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

it('watches attributes', inject(function ($compile) {
el = angular.element('<a ui-state="{{exprvar}}" ui-state-params="params">state</a>');
template = $compile(el)(scope);

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

scope.exprvar = 'state2';
scope.$digest();
expect(angular.element(template[0]).attr('href')).toBe('#/other/10');
}));

if (angular.version.minor >= 3) {
it('allows one-time-binding on ng1.3+', 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');

scope.state = 'other';
scope.params = {id: 22};

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

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

Expand All @@ -402,7 +456,7 @@ describe('uiStateRef', function() {
}));
});

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

beforeEach(inject(function($rootScope, $compile, $state) {
Expand Down

0 comments on commit 389dfd5

Please sign in to comment.