I have discovered a perplexing circumstance in which the $scope.watch is not triggering as I would expect. I am hoping some of you AngularJS buffs out there can help shed some light on why Angular is behaving so peculiarly with $scope.watch and Service objects. As a note this was discovered while I was messing with the Ionic framework.
Below is an example of what I am running, and it works as expected (to be used to maintain state information during the session.)
TL;DR:
Watch doesn't fire on the controller when I update currentUser with a new userObject (currentUser = new userObject();) in my service. It does fire if I instead update each individual attribute of the object.
currentUser.name = 'Foo';
currentUser.email = 'foo#bar.com';
I am seeking guidance on why so I can better understand.
Code
Service (Working)
angular.module('app.services')
.service( 'UserService', function() {
var userObject = function(){
return {
id: null,
username: null,
email: null,
fullName: null,
modified: null
}
};
var currentUser = new userObject();
var createUser = function(id, username, email, fullName, modified ){
var newUserObject = new userObject();
newUserObject.id = id;
newUserObject.username = username;
newUserObject.email = email;
newUserObject.fullName = fullName;
newUserObject.modified = (modified) ? modified : new Date();
return newUserObject;
};
var setCurrentUser = function(userObj){
console.log('First');
console.dir(currentUser);
setUserId(userObj.id);
setUsername(userObj.username);
setEmail(userObj.email);
setFullName(userObj.fullName);
setModifiedDate(userObj.modified);
console.log('Second');
console.dir(currentUser);
return currentUser;
};
});
Controller
angular.module('app.controllers')
.controller('DashboardCtrl', function ($scope, UserService) {
var dashboard = this;
dashboard.user = UserService.currentUser;
$scope.$watch('UserService.currentUser', function(newVal, oldVal) {
if (newVal !== oldVal ){
dashboard.user = newVal;
}
});
var createdUser = UserService.createUser(
1,
'user1',
'asdf#asdf.com',
'Test User',
new Date()
);
UserService.setCurrentUser(createdUser);
});
View
<div ng-controller="DashboardCtrl as dashboard">
<span bind="dashboard.user.fullName">Loading</span>
</div>
Result
WAHOO! Loading is replaced on init with '' (null) and immediately after it is updated by the watch with 'Test User' (faster than the human eye unless debugging)
Breaking Scenario
The code above works great. However I wanted to try and reduce code by reducing repetition of effort by creating a new UserObject and then setting that object as 'Current User'. The change I made was as follows (view and controller were unchanged):
Service (Broken)
angular.module('app.services')
.service( 'UserService', function() {
var userObject = function(){
return {
id: null,
username: null,
email: null,
fullName: null,
modified: null
}
};
var currentUser = new userObject();
var createUser = function(id, username, email, fullName, modified ){
var newUserObject = new userObject();
newUserObject.id = id;
newUserObject.username = username;
newUserObject.email = email;
newUserObject.fullName = fullName;
newUserObject.modified = (modified) ? modified : new Date();
return newUserObject;
};
var setCurrentUser = function(userObj){
console.log('First');
console.dir(currentUser);
// Set original 'currentUser' with a new UserObject passed by controller
currentUser = userObj;
console.log('Second');
console.dir(currentUser);
return currentUser;
};
});
Result
Loading is replaced with '' on init (null) and $scope.watch never triggers in this scenario. My expectation is that the watch should be doing a deep watch on the object, and when I replace the object with a new one it should trigger that the object changed and trigger the watch.
The only thing I can figure, is that when I replace the currentUser object with a new object is that the delegates for $watch are also lost on that object. Does anyone have insight on how I can tell?
Phew.
When you give a string to the $watch it checks that variable inside angular function, As you don't have UserService.currentUser inside your scope then that watcher function will never get fired.
For making your watch working, you need to use function instead of string, then that function will return an expression. By adding it in watcher function, so that it will get evaluated on each digest cycle & will perform dirty checking. If value gets change it will fire the watcher function
Code
$scope.$watch(function(){
return UserService.currentUser;
}, function(newVal, oldVal) {
if (newVal !== oldVal ){
dashboard.user = newVal;
}
});
Related
I am developping a web app with Ionic 1 and AngularJS 1.
In my factory (UserFact) :
.factory('UserFact', function() {
var user = [];
return {
'setUser': function(user) {
this.user = user;
console.log('(2) User set: ' + this.user);
console.log('(3) User id is now: ' + this.user.uid);
},
'updateSport': function(sportid, registered) {
console.log('Update sport: ' + sportid + ' --> ' + registered);
console.log('(4) For user uid: ' + this.user.uid);
var ref = firebase.database().ref('users/' + this.user.uid + '/sports/');
// sync down from server
var list = [];
ref.on('value', function(snap) { list = snap.val(); });
if(registered) {
list.splice(0, 0, {id: sportid});
} else {
}
ref.set(list);
}
};
})
In my controller :
function ($scope, $stateParams, $state, DatabaseFact, UserFact) {
// variables
$scope.sports = [];
$scope.sports = DatabaseFact.getSports();
// functions
$scope.updateSport = UserFact.updateSport;
// execution
firebase.auth().onAuthStateChanged(function(user) {
if (user) {
UserFact.setUser(user);
console.log('(1) Ctrl user uid: ' + user.uid);
}
});
}
According to the console: logs (1), (2) and (3) display a userid form my db but (4) is always undefined...
Any idea?
Thanks
UPDATE:
Manuel, sorry, I think I had missed the point of your question. You are correct, using a factory/service, is the correct way to store the state of your application. From the above, I do not see a reason for your code not work. The user must be getting re-assigned elsewhere for you to be seeing undefined in (4), if you are not seeing the same in (3). For simplicity sake, I removed references to firebase and created a working demo: https://plnkr.co/edit/vaN7ySche8GgRQmZgFsa?p=preview
While the demo may not solve your problem, I hope it illustrates that the factory variable (user) is persisted in memory and usable across multiple factory method calls.
ORIGINAL ANSWER (MISSED THE POINT) BELOW:
Update the state change handler to save the user on the controller scope:
firebase.auth().onAuthStateChanged(function(user) {
if (user) {
$scope.user = user;
UserFact.setUser(user);
console.log('(1) Ctrl user uid: ' + user.uid);
}
});
Then, in the template, invoke the updateSport method, using the user scope variable:
updateSport(user, true);
or
updateSport(user, false);
You need to pass the arguments while calling updateSport in controller,
$scope.updateSport = UserFact.updateSport(userid,registered);
I have a list of icons on a webpage generated from an object array. When the user clicks on an icon, the corresponding object from the array is passed to a function in a factory which saves the name of the object selected, then $state.go is called to change routes. On the new route a controller is loaded which loads the same factory and tries to access the name of the saved object. The problem is that about 7 times out of 10, it works perfectly, and the other 3 times is gives a "Unable to get property 'name' of undefined or null reference" type error.
Here is the controller passing the selected value to the factory:
platformHome.controller('PlatformHome', ['$scope', 'appManager', '$state', function ($scope, appManager, $state) {
var SF = appManager.state.SF;
var SO = appManager.state.SO;
$scope.productLineSelected = function (product) {
setProductLine(product);
};
function setProductLine(product) {
SF.setProduct(product);
$state.go('metricDashboard');
}
}]);
Here is the factory:
applicationManager.factory('appStateManager', ['$rootScope', '$sessionStorage', '$state', function ($rootScope, $sessionStorage, $state) {
// STATE OBJECT CLASSES
//
var stateClasses = {};
stateClasses.ProductLine = function (name) {
this.name = name;
this.dashboard = {
mode: 'reporting', //reporting, analysis
modeView: 'canvas', //canvas, data
index: {
report: 0,
userReport: 0,
canvas: 0,
group: 0,
element: 0,
filter: 0,
}
};
this.reports = [];
this.canvases = [new stateClasses.Canvas];
};
// STATE DATA FUNCTIONS
//
var stateFunctions = {};
stateFunctions.setProduct = function (product) {
session.StateObject.productLine.current = product.Code;
session.StateObject[product.Code] = (typeof session.StateObject[product.Code] === 'undefined') ? new stateClasses.ProductLine(product.Name) : session.StateObject[product.Code];
};
// STUCTURE
//
var stateScope = $rootScope.$new(true);
var session = $sessionStorage;
session.StateObject = (typeof session.StateObject === 'undefined') ? new stateClasses.StateObject : session.StateObject;
stateScope.SO = session.StateObject;
stateScope.SF = stateFunctions;
return stateScope;
}]);
Here is the controller trying to access the name:
metricDashboard.controller('MetricDashboard', ['$scope', 'appManager', function ($scope, appManager) {
var SF = appManager.state.SF;
var SO = appManager.state.SO;
DSO = SO[SO.productLine.current];
$scope.name = DSO.name;
}]);
I suspect that the issue is related to the order in which things are happening, however, I cannot figure out why it works 7 times out of 10.
When I do get the error, I have been able to determine that the line SO.productLine.current in the second controller has a value of none, meaning it doesn't seem to have been updated from the scope of the controller, however, at the same time, I'm also using console.log(JSON.stringify()) inside the factory, and the factory does indeed show a proper value instead of none.
I've also tried using $timeout on $state.go, and also tried passing it as a callback, however neither of those prevent the issue. Again 7 times out of 10, the code runs fine and the name property is value, but sometimes its not.
I was able to correct the problem with a few steps. The idea is to remove the assignment of the DSO variable from the MetricDashboard controller and move that functionality of assignment into the factory, then simply reference the newly assigned variable back in the controller.
Here are the changes:
In Factory
...
// STATE DATA FUNCTIONS
//
var stateFunctions = {};
stateFunctions.setProduct = function (product) {
session.StateObject.productLine.current = product.Code;
session.StateObject[product.Code] = (typeof session.StateObject[product.Code] === 'undefined') ? new stateClasses.ProductLine(product.Name) : session.StateObject[product.Code];
//new functionality for assignment
session.DynamicStateObject = session.StateObject[product.Code];
stateScope.DSO = session.DynamicStateObject;
};
// STUCTURE
//
var stateScope = $rootScope.$new(true);
var session = $sessionStorage;
session.StateObject = (typeof session.StateObject === 'undefined') ? new stateClasses.StateObject : session.StateObject;
//new structure to persist assignment beyond page refresh
session.DynamicStateObject = (typeof session.DynamicStateObject === 'undefined') ? {} : session.DynamicStateObject;
//new reference
stateScope.DSO = session.DynamicStateObject;
stateScope.SO = session.StateObject;
stateScope.SF = stateFunctions;
return stateScope;
In Controller
var SF = appManager.state.SF;
var SO = appManager.state.SO;
//Removed assignmnet
//DSO = SO[SO.productLine.current];
//Added reference
var DSO = appManager.state.DSO;
$scope.name = DSO.name;
While I have not yet tested the new code extensively, I have not been able reproduce the error.
I have a controller 'AController' that has a template variable, $scope.tpl.partialtemplate1 = 'initialcontactlist.html'.
'AController' basically is in charge of an entire page, 'mainpage.html', where we have
<div ng-include="tpl.partialtemplate1"></div>
On 'mainpage.html', there is a form to add contacts. This form is not part of 'partialtemplate1''s views.
Upon submitting the form, I want the HTML view for 'partialtemplate1' to be reset to what it was on initial page load, because it will then reload the latest list of contacts.
I have tried things like incrementing a variable after each new contact is successfully added, and then having that variable watched and the partialtemplate variable changed.
for example, in 'AController':
$scope.tpl = {};
$scope.contactcount = 0;
$scope.contactsignupdata = new Contact();
$scope.tpl.partialtemplate1 = 'initialcontactlist.html';
$scope.successmessage = null;
$scope.addcontact = function() {
$scope.contactsignupdata.$save();
$scope.successmessage = 'Saved!';
$scope.contactsignupdata = new Contact();
$scope.contactcount = $scope.contactcount + 1;
};
$scope.$watch('contactcount', function(newValue, oldValue) {
$scope.$apply(function() {
$scope.tpl.partialtemplate1 = null;
$scope.tpl.partialtemplate1 = 'initialcontactlist.html';
});
/*$scope.partialtemplate1 = 'projecttasklists.html';*/
});
Why isn't the partialtemplate variable getting changed? Yes, the contact gets successfully saved each time - I took care of that with the Rails factory...
Your code sets partialtemplate1 to null, then straight back to 'initialcontactlist.html'. As far as Angular is concerned, nothing is changed. True bindings are not supported meaning that just because you changed partialtemplate1, doesn't mean it immediately happens or triggers any special events. For this specific scenario, you would have to set partialtemplate1 to null, set a timer, then trigger the change back to 'initialcontactlist.html'
I do not recommend this by the way
$scope.$watch('contactcount', function(newValue, oldValue) {
$scope.$apply(function() {
$scope.tpl.partialtemplate1 = null;
$timeout(function() {
$scope.tpl.partialtemplate1 = 'initialcontactlist.html';
}, 1000);
});
});
I highly recommend
Creating an API for Contacts that you can query. That way when a Contact is created, updated, or removed you can handle it yourself in a couple ways:
You can requery the data source each time something changes
If the API returns data related to the change, you don't need to requery
You should look into creating Angular Services and/or Factories to handle this. In fact it is quite easy to implement if it is a true REST API using $resource. If it is not a RESTful resource, you can use $http for custom queries
I solved this problem with $emit.
In the HTML file, upon pressing the submit button for the "Add a contact" form, two events are triggered (separated by the apostrophe button).
ng-click="addcontact(contactsignupdata.name);$emit('MyEvent')"
</form>
{{successmessage}}
In the controller file:
$scope.successmessage = null;
$scope.tpl = {};
$scope.tpl.partialtemplate1 = 'initialcontactlist.html';
$scope.addcontact = function(value) {
$scope.contactsignupdata.$save();
$scope.successmessage = 'Saved ' + $scope.contactsignupdata.name;
$scope.contactsignupdata = new Contact();
};
$scope.$on('MyEvent', function() {
$scope.tpl.partialtemplate1 = null;
$scope.resettofullcontactslist($scope.tpl.partialtemplate1);
});
$scope.resettofullcontactslist = function(value) {
$scope.tpl.partialtemplate1 = 'initialcontactlist.html';
};
OK switching my code to angularjs and the angular 'way', not sure what I am doing wrong.
A select list is not getting updated when the model changes unless I call $apply, and I find myself calling apply a lot.
index.html has this:
<div id='rightcol' data-ng-include="'partials/rightSidebar.html'"
data-ng-controller="rightSidebarController">
</div>
and rightSidebar.html has this:
<select id='srcList' size='10'
data-ng-model="data.source"
data-ng-click='srcOnclick()'
data-ng-options="s.title for s in data.srcList | filter:{title:data.srcFilter} | orderBy:'title'"></select>
rightSidebarController.js has this:
$scope.data = {};
$scope.data.srcList = dataProvider.getSourceList();
$scope.data.source = dataProvider.getSource();
dataProvider is a service that makes an asynchronous database call (IndexedDB) to populate srcList, which is what gets returned in dataProvider.getSource().
Is it the asynchronous database call that forces me to call $apply, or should the controller be ignorant of that?
Is there a 'better' way to do this?
Edited to add service code.
Another controller calls dataProvider.refreshSourceList:
myDB.refreshSourceList = function() {
myDB.getRecords("source", function(recs) {
myDB.srcList = recs;
$rootScope.$broadcast('SrcListRefresh');
});
};
myDB.srcList is the field being bound by $scope.data.srcList = dataProvider.getSourceList();
myDB.getRecords:
myDB.getRecords = function(storeName, callback) {
var db = myDB.db;
var recList = [];
var trans = db.transaction([storeName], 'readonly');
var store = trans.objectStore(storeName);
var cursorRequest = store.openCursor();
cursorRequest.onerror = myDB.onerror;
cursorRequest.onsuccess = function(e) {
var cursor = cursorRequest.result || e.result;
if (cursor === false || cursor === undefined) {
if (callback !== undefined) {
$rootScope.$apply(function() {
callback(recList);
});
}
} else if (cursor.value !== null) {
recList.push(cursor.value);
cursor.continue();
}
};
cursorRequest.onerror = myDB.onerror;
};
Anything you do async needs to be wrapped in $scope.$apply(). This is because angular works in a similar fashion to a game loop, however instead of constantly running, it knows to end the loop when an action is taken, and $scope.$digest() is called.
If you are using IndexedDB, I would recommend creating an angular wrapper for it, like so:
(forgive my IndexedDB code, I'm not experience with it)
angular.module('app',[])
.factory('appdb', function($rootScope){
var db = indexedDB.open('appdb', 3);
return {
get : function(table, query, callback) {
var req = db.transaction([table])
.objectStore(table)
.get(query);
req.onsuccess(function(){
$rootScope.$apply(function(){
callback(req.result);
});
});
}
};
});
This way you can be sure that any data retrieve and set on a controller scope inside of callback will have $scope.$digest() called afterward.
I have a view Transaction which has two sections
a.) view-transaction
b.) add-transaction
both are tied to the following controller
function TransactionController($scope, Category, Transaction) {
$scope.categories = Category.query(function() {
console.log('all categories - ', $scope.categories.length);
});
$scope.transactions = Transaction.query();
$scope.save = function() {
var transaction = new Transaction();
transaction.name = $scope.transaction['name'];
transaction.debit = $scope.transaction['debit'];
transaction.date = $scope.transaction['date'];
transaction.amount = $scope.transaction['amount'];
transaction.category = $scope.transaction['category'].uuid;
//noinspection JSUnresolvedFunction
transaction.$save();
$scope.transactions.push(transaction);
console.log('transaction saved successfully', transaction);
}
}
, where Transaction is a service and looks as follows
angular.module('transactionServices', ['ngResource']).factory('Transaction', function($resource) {
return $resource('/users/:userId/transactions/:transactionId', {
// todo: default user for now, change it
userId: 'bd675d42-aa9b-11e2-9d27-b88d1205c810',
transactionId: '#uuid'
});
});
When i click on tab "Transaction", the route #/transactions is activated, causing it to render both sub-views a.) and b.)
The question that I have is,
- Is there a way to update the $scope.transactions whenever I add new transaction? Since it is a resource
or I will have to manually do $scope.transactions.push(transaction);
My very first answer so take it easy on me...
You can extend the Transaction resource to update the $scope.transactions for you. It would be something like:
angular.module( ..., function($resource) {
var custom_resource = $resource('/users/:userId/transactions/:transactionId', {
...
});
custom_resource.prototype.save_and_update = function (transactions) {
var self = this;
this.$save(function () {
transactions.push(self);
});
};
return custom_resource;
});
In you controller, you would then do:
function TransactionController (...) {
...
$scope.save = function () {
...
// In place of: transaction.$save(), do:
transaction.save_and_update($scope.transactions);
...
}
}
Note: You need to make sure that object you created is fully usable in $scope. I spent 30 min trying to figure why this method failed on my code and it turn out that I am generating identity code in the database. As result, all my subsequent action on added new object failed because the new object was missing the identity!!!
There is no way to update a set of models in the scope automatically. You can push it into the $scope.transactions, or you can call a method that updates $scope.transactions with fresh data from the server. In any case, you should update the $scope in the success callback of your resource save function like this:
transaction.$save({}, function() {
$scope.transactions.push(transaction);
//or
$scope.transactions = Transaction.query();
});
In your example, when you push the transaction, you cannot be sure that the model has been saved successfully yet.
Another tip: you can create the new Transaction before you save it, and update the model directly from your view:
$scope.newTransaction = new Transaction();
$scope.addTransaction = function() {
$scope.newTransaction.save( ...
}
And somewhere in your view:
<input type="text" ng-model="newTransaction.name" />
The ng-model directive ensures that the input is bound to the name property of your newTransaction model.