Skip to content

Commit 7a82df7

Browse files
committed
feat($state): params are now observable
`$stateParams` is now a service with an `$observe()` method to watch parameters on dynamic state parameters. Dynamic state parameters can be declared like so: `params: { foo: { dynamic: true } }`. Closes #1037, #1038, #64
1 parent ebd68d7 commit 7a82df7

File tree

6 files changed

+180
-35
lines changed

6 files changed

+180
-35
lines changed

src/common.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ function filterByKeys(keys, values) {
142142
var filtered = {};
143143

144144
forEach(keys, function (name) {
145-
filtered[name] = values[name];
145+
if (isDefined(values[name])) filtered[name] = values[name];
146146
});
147147
return filtered;
148148
}

src/state.js

+102-13
Original file line numberDiff line numberDiff line change
@@ -801,15 +801,30 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
801801
}
802802
}
803803

804-
// If we're going to the same state and all locals are kept, we've got nothing to do.
805-
// But clear 'transition', as we still want to cancel any other pending transitions.
806-
// TODO: We may not want to bump 'transition' if we're called from a location change
807-
// that we've initiated ourselves, because we might accidentally abort a legitimate
808-
// transition initiated from code?
809-
if (shouldTriggerReload(to, from, locals, options)) {
810-
if (to.self.reloadOnSearch !== false) $urlRouter.update();
811-
$state.transition = null;
812-
return $q.when($state.current);
804+
if (to === from && !options.reload) {
805+
var isDynamic = false;
806+
807+
if (toState.url) {
808+
var changes = {};
809+
810+
forEach(toParams, function(val, key) {
811+
if (val != $stateParams[key]) changes[key] = val;
812+
});
813+
814+
isDynamic = objectKeys(changes).length && $urlRouter.isDynamic(toState.url, changes);
815+
816+
if (isDynamic) {
817+
$stateParams.$set(changes);
818+
$urlRouter.push(toState.url, $stateParams, { replace: true });
819+
$urlRouter.update(true);
820+
}
821+
}
822+
823+
if (isDynamic || locals === from.locals) {
824+
if (!isDynamic) $urlRouter.update();
825+
$state.transition = null;
826+
return $q.when($state.current);
827+
}
813828
}
814829

815830
// Filter parameters before we pass them to event handlers etc.
@@ -900,6 +915,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
900915
$state.params = toParams;
901916
copy($state.params, $stateParams);
902917
$state.transition = null;
918+
$stateParams.$sync();
919+
$stateParams.$off();
903920

904921
if (options.location && to.navigable) {
905922
$urlRouter.push(to.navigable.url, to.navigable.locals.globals.$stateParams, {
@@ -1180,14 +1197,86 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
11801197

11811198
return $state;
11821199
}
1200+
}
11831201

1184-
function shouldTriggerReload(to, from, locals, options) {
1185-
if (to === from && ((locals === from.locals && !options.reload) || (to.self.reloadOnSearch === false))) {
1186-
return true;
1202+
$StateParamsProvider.$inject = [];
1203+
function $StateParamsProvider() {
1204+
1205+
var observers = {}, current = {};
1206+
1207+
function unhook(key, func) {
1208+
return function() {
1209+
forEach(key.split(" "), function(k) {
1210+
observers[k].splice(observers[k].indexOf(func), 1);
1211+
});
11871212
}
11881213
}
1214+
1215+
function observeChange(key, val) {
1216+
if (!observers[key] || !observers[key].length) return;
1217+
1218+
forEach(observers[key], function(func) {
1219+
func();
1220+
});
1221+
}
1222+
1223+
function StateParams() {
1224+
}
1225+
1226+
StateParams.prototype.$digest = function() {
1227+
forEach(this, function(val, key) {
1228+
if (val == current[key] || !this.hasOwnProperty(key)) return;
1229+
current[key] = val;
1230+
observeChange(key, val);
1231+
}, this);
1232+
};
1233+
1234+
StateParams.prototype.$set = function(params) {
1235+
forEach(params, function(val, key) {
1236+
this[key] = val;
1237+
observeChange(key);
1238+
}, this);
1239+
this.$sync();
1240+
};
1241+
1242+
StateParams.prototype.$sync = function() {
1243+
copy(this, current);
1244+
};
1245+
1246+
StateParams.prototype.$off = function() {
1247+
observers = {};
1248+
};
1249+
1250+
StateParams.prototype.$localize = function(state) {
1251+
var localized = new StateParams();
1252+
1253+
forEach(state.params, function(val, key) {
1254+
localized[key] = this[key];
1255+
}, this);
1256+
return localized;
1257+
};
1258+
1259+
StateParams.prototype.$observe = function(key, func) {
1260+
forEach(key.split(" "), function(k) {
1261+
(observers[k] || (observers[k] = [])).push(func);
1262+
});
1263+
return unhook(key, func);
1264+
};
1265+
1266+
this.$get = $get;
1267+
$get.$inject = ['$rootScope'];
1268+
function $get( $rootScope) {
1269+
1270+
var global = new StateParams();
1271+
1272+
$rootScope.$watch(function() {
1273+
global.$digest();
1274+
});
1275+
1276+
return global;
1277+
}
11891278
}
11901279

11911280
angular.module('ui.router.state')
1192-
.value('$stateParams', {})
1281+
.provider('$stateParams', $StateParamsProvider)
11931282
.provider('$state', $StateProvider);

src/urlRouter.js

+25
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,31 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) {
397397
port = (port === 80 || port === 443 ? '' : ':' + port);
398398

399399
return [$location.protocol(), '://', $location.host(), port, slash, url].join('');
400+
},
401+
402+
/**
403+
* @ngdoc function
404+
* @name ui.router.router.$urlRouter#shouldReload
405+
* @methodOf ui.router.router.$urlRouter
406+
*
407+
* @description
408+
* Accepts a set of parameters and determines whether a reload should be performed, or
409+
* whether the parameters should be updated inline (i.e. if the parameters have been
410+
* flagged as being dynamic).
411+
*
412+
* @param {UrlMatcher} url The URL against which the parameters are being checked.
413+
* @param {Object} params The object hash of parameters to check.
414+
* @return {Boolean} Returns `true` if the parameters should be updated inline, or `false`
415+
* if they should trigger a reload.
416+
*/
417+
isDynamic: function(urlMatcher, params) {
418+
var cfg, result = objectKeys(params).length > 0;
419+
420+
forEach(params, function(val, key) {
421+
cfg = urlMatcher.parameters(key) || {};
422+
result = result && (cfg.dynamic === true);
423+
});
424+
return result;
400425
}
401426
};
402427
}

test/stateDirectivesSpec.js

+15-9
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe('uiStateRef', function() {
125125
$q.flush();
126126

127127
expect($state.current.name).toEqual('contacts.item.detail');
128-
expect($stateParams).toEqual({ id: 5 });
128+
expect($stateParams).toEqualData({ id: 5 });
129129
}));
130130

131131
it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $q) {
@@ -142,51 +142,55 @@ describe('uiStateRef', function() {
142142
$q.flush();
143143

144144
expect($state.current.name).toEqual('contacts.item.detail');
145-
expect($stateParams).toEqual({ id: 5 });
145+
expect($stateParams).toEqualData({ id: 5 });
146146
}));
147147

148148
it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $q) {
149149
expect($state.$current.name).toEqual('');
150-
triggerClick(el, { ctrlKey: true });
150+
expect($stateParams).toEqualData({});
151151

152+
triggerClick(el, { ctrlKey: true });
152153
timeoutFlush();
153154
$q.flush();
154155

155156
expect($state.current.name).toEqual('');
156-
expect($stateParams).toEqual({ id: 5 });
157+
expect($stateParams).toEqualData({});
157158
}));
158159

159160
it('should not transition states when meta-clicked', inject(function($state, $stateParams, $q) {
160161
expect($state.$current.name).toEqual('');
162+
expect($stateParams).toEqualData({});
161163

162164
triggerClick(el, { metaKey: true });
163165
timeoutFlush();
164166
$q.flush();
165167

166168
expect($state.current.name).toEqual('');
167-
expect($stateParams).toEqual({ id: 5 });
169+
expect($stateParams).toEqualData({});
168170
}));
169171

170172
it('should not transition states when shift-clicked', inject(function($state, $stateParams, $q) {
171173
expect($state.$current.name).toEqual('');
174+
expect($stateParams).toEqualData({});
172175

173176
triggerClick(el, { shiftKey: true });
174177
timeoutFlush();
175178
$q.flush();
176179

177180
expect($state.current.name).toEqual('');
178-
expect($stateParams).toEqual({ id: 5 });
181+
expect($stateParams).toEqualData({});
179182
}));
180183

181184
it('should not transition states when middle-clicked', inject(function($state, $stateParams, $q) {
182185
expect($state.$current.name).toEqual('');
186+
expect($stateParams).toEqualData({});
183187

184188
triggerClick(el, { button: 1 });
185189
timeoutFlush();
186190
$q.flush();
187191

188192
expect($state.current.name).toEqual('');
189-
expect($stateParams).toEqual({ id: 5 });
193+
expect($stateParams).toEqualData({});
190194
}));
191195

192196
it('should not transition states when element has target specified', inject(function($state, $stateParams, $q) {
@@ -198,11 +202,13 @@ describe('uiStateRef', function() {
198202
$q.flush();
199203

200204
expect($state.current.name).toEqual('');
201-
expect($stateParams).toEqual({ id: 5 });
205+
expect($stateParams).toEqualData({});
202206
}));
203207

204208
it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $q) {
205209
expect($state.$current.name).toEqual('');
210+
expect($stateParams).toEqualData({});
211+
206212
el.bind('click', function(e) {
207213
e.preventDefault();
208214
});
@@ -212,7 +218,7 @@ describe('uiStateRef', function() {
212218
$q.flush();
213219

214220
expect($state.current.name).toEqual('');
215-
expect($stateParams).toEqual({ id: 5 });
221+
expect($stateParams).toEqualData({});
216222
}));
217223

218224
it('should allow passing params to current state', inject(function($compile, $rootScope, $state) {

test/stateSpec.js

+23-12
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('state', function () {
2626
H = { data: {propA: 'propA', propB: 'propB'} },
2727
HH = { parent: H },
2828
HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} },
29-
RS = { url: '^/search?term', reloadOnSearch: false },
29+
RS = { url: '^/search?term', params: { term: { dynamic: true } } },
3030
AppInjectable = {};
3131

3232
beforeEach(module(function ($stateProvider, $provide) {
@@ -152,16 +152,27 @@ describe('state', function () {
152152
expect($state.current).toBe(A);
153153
}));
154154

155-
it('doesn\'t trigger state change if reloadOnSearch is false', inject(function ($state, $q, $location, $rootScope){
156-
initStateTo(RS);
157-
$location.search({term: 'hello'});
158-
var called;
155+
it('does not trigger state change if params are dynamic', inject(function ($state, $q, $location, $rootScope, $stateParams) {
156+
var called = { change: false, observe: false };
157+
initStateTo(RS, { term: 'goodbye' });
158+
159+
$location.search({ term: 'hello' });
160+
expect($stateParams.term).toBe("goodbye");
161+
159162
$rootScope.$on('$stateChangeStart', function (ev, to, toParams, from, fromParams) {
160-
called = true
163+
called.change = true;
161164
});
165+
166+
$stateParams.$observe('term', function(val) {
167+
called.observe = true;
168+
});
169+
162170
$q.flush();
163-
expect($location.search()).toEqual({term: 'hello'});
164-
expect(called).toBeFalsy();
171+
expect($location.search()).toEqual({ term: 'hello' });
172+
expect($stateParams.term).toBe('hello');
173+
174+
expect(called.change).toBe(false);
175+
expect(called.observe).toBe(true);
165176
}));
166177

167178
it('ignores non-applicable state parameters', inject(function ($state, $q) {
@@ -478,7 +489,7 @@ describe('state', function () {
478489
$q.flush();
479490

480491
expect($state.$current.name).toBe('about.person.item');
481-
expect($stateParams).toEqual({ person: 'bob', id: 5 });
492+
expect($stateParams).toEqualData({ person: 'bob', id: 5 });
482493

483494
$state.go('^.^.sidebar');
484495
$q.flush();
@@ -909,7 +920,7 @@ describe('state', function () {
909920
$state.go('root.sub1', { param2: 2 });
910921
$q.flush();
911922
expect($state.current.name).toEqual('root.sub1');
912-
expect($stateParams).toEqual({ param1: 1, param2: 2 });
923+
expect($stateParams).toEqualData({ param1: 1, param2: 2 });
913924
}));
914925

915926
it('should not inherit siblings\' states', inject(function ($state, $stateParams, $q) {
@@ -922,7 +933,7 @@ describe('state', function () {
922933
$q.flush();
923934
expect($state.current.name).toEqual('root.sub2');
924935

925-
expect($stateParams).toEqual({ param1: 1, param2: undefined });
936+
expect($stateParams).toEqualData({ param1: 1, param2: undefined });
926937
}));
927938
});
928939

@@ -1017,7 +1028,7 @@ describe('state', function () {
10171028
});
10181029
});
10191030

1020-
describe('state queue', function(){
1031+
describe('state queue', function() {
10211032
angular.module('ui.router.queue.test', ['ui.router.queue.test.dependency'])
10221033
.config(function($stateProvider) {
10231034
$stateProvider

test/urlRouterSpec.js

+14
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,20 @@ describe("UrlRouter", function () {
199199
expect($urlRouter.href(matcher, { param: 5 })).toBeNull();
200200
}));
201201
});
202+
203+
describe("reload checking", function() {
204+
it("should check UrlMatcher parameters", inject(function($urlRouter) {
205+
var m = new UrlMatcher('/:foo/:bar/:baz', {
206+
params: {
207+
foo: { dynamic: true },
208+
bar: { dynamic: true },
209+
baz: {}
210+
}
211+
});
212+
expect($urlRouter.isDynamic(m, { foo: "1", bar: "2" })).toBe(true);
213+
expect($urlRouter.isDynamic(m, { bar: "1", baz: "2" })).toBe(false);
214+
}));
215+
});
202216
});
203217

204218
});

0 commit comments

Comments
 (0)