In my previous blog articles I had explained how to create CFMobile applications using JQuery, Bootstrap/JQueryMobile. Here are links to sample (CFMobileExpenseTracker) applications using the two UI frameworks –
- CFMoibleEpenseTracker using Bootstrap . Blog article.
- CFMobileExpenseTracker using JQueryMobile. Blog article.
I wanted to create the same application using AngularJS. It had been on my ‘to learn’ list for sometime now. So I spent the last weekend learning it. If you already know concepts of MVC and Dependency Injection, then understanding AngularJS is not difficult. The well documented tutorials also helped.
I decided to re-write CFMobileExpenseTracker using AngularJS and JQueryMobile. Since AngularJS provides very easy way to manipulate DOM, you really don’t need JQuery. But I had to include it in the application anyway because JQueryMobile depends on it. I also used JQuery for basic event handling.
Earlier in my application I had used client side custom tag (expenseList.cfm) to display expense items by calling JQuery APIs to modify DOM and update UI. I could get rid of this custom tag entirely after using AngularJS, because of templating features and automatic synchronisation between model and view provided by Angular JS.
Here are the screen shots of the application –
Though I said that I re-wrote the application, it was not a complete re-write. I could reuse CFCs and made small modifications to index_include.cfm. I added a new JS file (angular_app.js) to crate AngularJS application and controllers –
expesneTrackerApp = angular.module("expenseTrackerApp",[]); expesneTrackerApp.controller( "expnseItemsCtrl", [ "$scope", function ($scope) { $scope.expenses = []; $scope.addExpense = function (expense) { $scope.expenses.push(expense); } $scope.dateToStr = function (dateNum) { var tmpDate = new Date(dateNum); return dateFormat(tmpDate,"mm/dd/yyyy"); } } ] ).controller ( "addExpDlgCtrl", [ "$scope", function ($scope) { $scope.resetExpense = function () { $scope.amount = 0; $scope.desc = ""; $scope.date = ""; $scope.imagePath = ""; } $scope.resetExpense(); } ] ).controller ( "displayReceiptCtrl", [ "$scope", function ($scope) { $scope.resetData = function() { $scope.imagePath = ""; } $scope.resetData(); } ] )
I created the single expesneTrackerApp AngularJS application and added three controllers to it –
expnseItemsCtrl – Contains list of expense items.
addExpDlgCtrl – Contains values of new expense items
displayReceiptCtrl – Contains file path of image associated with an expense item.
Note that dateFormat function used in expnseItemsCtrl is a built-in cfclient function.
Here is the new index.cfm – not much changed except attributes in HTML elements to associate with AngularJS controller and use of templating features, using {{}}, of AngularJS to display expense items.
<!DOCTYPE html> <html ng-app="expenseTrackerApp"> <head> <META HTTP-EQUIV="CACHE-CONTROL" CONTENT="NO-CACHE"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="css/jquery.mobile-1.4.2.min.css" ></link> <script src="js/jquery-2.1.0.min.js" ></script> <script src="js/app.js"></script> <script src="js/jquery.mobile-1.4.2.min.js" ></script> <script src="js/angular.min.js" ></script> <script src="js/angular_app.js"></script> <style > table th { text-align:left; } </style> </head> <body > <!--- Main JQuery Mobile poage ---> <div data-role="page" id="mainPage"> <div data-role="header" data-position="fixed" > <h2>Expense Tracker</h2> <div style="padding-left:10px"> <button class="ui-btn ui-btn-inline" id="addBtn">Add</button> <button class="ui-btn ui-btn-inline" id="deleteAllBtn">Delete All</button> </div> </div> <div class="ui-content"> <!--- content will be dymanically populated ---> <div ng-controller="expnseItemsCtrl" id="angularDiv1"> <table width="100%" ng-if="expenses.length > 0"> <tr> <th>Date</th> <th>Amount</th> <th>Description</th> <th></th> </tr> <tr ng-repeat="expense in expenses" > <td>{{dateToStr(expense.expenseDate)}}</td> <td>{{expense.amount}}</td> <td>{{expense.description}}</td> <td> <span ng-if="expense.receiptPath.length > 0"> <a href="{{expense.receiptPath}}" class="imageReceipt">receipt</a> </span> <span ng-if="expense.receiptPath.length == 0"></span> </td> </tr> </table> <div ng-if="expenses.length == 0"> No expenses found </div> </div> </div> <div data-role="footer" data-position="fixed" > <h5>Created with CFMobile</h5> </div> </div> <!--- JQM Dialog box to add an epxense item ---> <div data-role="page" id="addDlg" > <div data-role="header"> <h2>Add Expense</h2> </div> <div class="ui-content" ng-controller="addExpDlgCtrl" id="addDlgContent"> <table width="100%"> <tr> <td>Date:</td> <td><input type="date" id="dateTxt" ng-model="date"></td> </tr> <tr> <td>Amount:</td> <td><input type="text" id="amtTxt" ng-model="amount"></td> </tr> <tr> <td>Desc:</td> <td><input type="text" id="descTxt" ng-model="desc"></td> </tr> <tr> <td colspan="2"><button class="ui-btn ui-btn-inline" id="attachRcptBtn">Attach Receipt</button></td> </tr> <tr> <td colspan="2"><img id="receiptImg" width="50" ng-src="{{imagePath}}" ng-show="imagePath.length > 0"></img></td> </tr> </table> </div> <div data-role="footer"> <button class="ui-btn ui-btn-inline" id="dlgOKBtn1">OK</button> <button class="ui-btn ui-btn-inline" id="dlgCancel1">Cancel</button> </div> </div> <!--- JQM Dialog box to display receipt ---> <div data-role="dialog" id="receiptDlg" style="overflow:scroll"> <div data-role="header"> <h2>Receipt</h2> <div> <button class="ui-btn ui-btn-inline" id="receiptFitBtn">Fit</button> <button class="ui-btn ui-btn-inline" id="receiptFullBtn">Full Size</button> </div> </div> <div class="ui-content" style="overflow:scroll" ng-controller="displayReceiptCtrl"> <img id="receiptImgLarge" ng-src="{{imagePath}}"> </div> <div data-role="footer"> </div> </div> </body> </html> <!--- We will be using device APIs. So enable them ---> <cfclientsettings enabledeviceapi="true" > <cfclient> <!--- All cfclient code to creat dynamic HTML and interface with data access CFC is in the following included file ---> <cfinclude template="index_include.cfm" > </cfclient>
Notice <html> tag is associated with AngularJS application (expenseTrackerApp) created in angular_app.js. In earlier applications, expense HTML table was created and populated in the expenseList.cfm custom tag. Now HTML table is created in index.cfm and populated using expnseItemsCtrl controller (in angular_app.js). Div ‘angularDiv1’ (I know, I should have used a better name) is attached to expnseItemsCtrl using ng-controller attribute. And expense items are displayed in the table using ng-repeat iterator in <tr> tag.
addExpDlgCtrl controller is attached to addDlgContent div in the dialog box to add new expense item. displayReceiptCtrl controller is attached to a div in the dialog box to display receipt. Notice use of ng-model attributes in input text boxes to link them to values in the controller. Also image source it also attached to imagePath in the controller, using ng-src attribute.
app.js file contains JS event handlers –
//This file included in index.cfm //Handle mobileinit event of JQuery Mobile $(document).on("mobileinit", function(){ //perform any initialization, if required }); //Handle pagecreate event of mainPage (JQM). Page is in index.cfm $(document).on("pagecreate", "#mainPage", function(){ $("#addBtn").on("vclick", function(){ $("#addDlg").dialog({closeBtn:"none"}); var scope = angular.element("#addDlgContent").scope(); scope.resetExpense(); scope.$digest(); _tmpImagePath = ""; $.mobile.changePage("#addDlg",{role:"dialog"}); }); //Button event handler for Delete All button $("#deleteAllBtn").on("vclick", function(){ deleteAll(); //in index_include.cfm }); //Hyper link event handler for 'receipt' $(document).on("click",".imageReceipt", function(event){ var imagePath = $(event.target).attr("href"); if (imagePath != "") { var scope = angular.element("#receiptImgLarge").scope(); scope.$apply(function($scope){ $scope.imagePath = imagePath; $("#receiptDlg").dialog({closeBtn:"right"}); $.mobile.changePage("#receiptDlg",{role:"dialog"}); //$("#receiptImgLarge").width($("#receiptDlg .ui-dialog-contain").width()); loadImage(imagePath,"receiptImgLarge"); //in index_include.cfm }); } event.preventDefault(); return false; }); }); //JQM pagecreate event for add dialog $(document).on("pagecreate","#addDlg", function(){ $("#dlgOKBtn1").on("vclick", function(){ saveExpense(); //in index_include.cfm }); //Button event handler for Cancel button $("#dlgCancel1").on("vclick", function(){ $("#addDlg").dialog("close"); //delete receipt, if it was attached if (_tmpImagePath != "") { deleteFile(_tmpImagePath); //in index_include.cfm _tmpImagePath = ""; } }); //Button event handler for Attach Receipt button $("#attachRcptBtn").on("vclick", function(){ attachReceipt(); //in index_include.cfm }); }); //JQM pagecreate event to display receipt dialog $(document).on("pagecreate","#receiptDlg", function(){ //Button event handler for 'Fit' image to window button $("#receiptFitBtn").on("vclick", function(){ $("#receiptImgLarge").css("width","100%"); }); //Button event handler for 'Full Size' (image) button $("#receiptFullBtn").on("vclick", function(){ $("#receiptImgLarge").css("width",''); }); });
Since data is updated in event handlers in this fale are outside the context of AngularJS, I first get scope associated with the required element, update the values and then either call $apply or $digest to let AngularJS watchers know that data has changed – watchers then update the UI with new data. See event handlers for #addBtn and .imageReceipt in the above code.
index_include.cfm contains cfclient code. It first creates an instance of ExpenseManager CFC and then calls getExpenses method on it to get array of ExpenseVO objects. Earlier, this array was passed to the client side custom tag, expense_list.cfm to display it in HTML table. That code is now changed. We need to let AngularJS controller, expnseItemsCtrl, know that data has changed. Because this code is outside the context of AngularJS we will have to use scope.$apply method again to propagate changes to all UI watchers. Here is the complete code in index_include.cfm –
<cfclient> <!--- included from index.html Contains code to create HTML dynamically and acts as interface between HTML UI and data access code in the ExpenseManager.cfc ---> <!--- Folder name where receipt images will be saved ---> <cfset variables.appFolderName = "CFMobileExpenseTracker"> <cftry> <!--- Create an instance of ExpenseManager.cfc and get all expenses from it ---> <cfset expMgr = new cfc.ExpenseManager()> <cfset expenses = expMgr.getExpenses()> <cfscript> angular.element("##angularDiv1").scope().$apply(function($scope){ $scope.expenses = expenses; }); </cfscript> <cfcatch type="any" name="e"> <cfset alert(e.message)> </cfcatch> </cftry> <!--- Reads expense item details from HTML input fields and calls ExpenseManager.addExpense to save to the database ---> <cffunction name="saveExpense" > <cfscript> var scope = angular.element("##addDlgContent").scope(); var dateStr = scope.date; var amtStr = trim(scope.amount); if (dateStr == "" || amtStr == "") { alert("Date and amount are required"); return; } if (!isNumeric(amtStr)) { alert("Invalid amount"); return; } var amt = Number(amtStr); var tmpDate = new Date(dateStr); var desc = trim(scope.desc); var receiptPath = ""; if (isDefined("_tmpImagePath")) receiptPath = _tmpImagePath; var expVO = new cfc.ExpenseVO(tmpDate.getTime(),amt,desc,receiptPath); var expAdded = false; try { expMgr.addExpense(expVO); expAdded = true; } catch (any e) { alert("Error : " + e.message); return; } </cfscript> <cfset $("##addDlg").dialog("close") > <cfif expAdded eq true> <cfscript> angular.element("##angularDiv1").scope().$apply(function($scope){ $scope.addExpense(expVO); }); </cfscript> </cfif> </cffunction> <!--- Calls ExpenseManager.deleteAllExpenses and then removes all items from the HTML table ---> <cffunction name="deleteAll" > <cfscript> if (!confirm("Are you sure you want to delete all?")) return; try { expMgr.deleteAllExpenses(); } catch (any e) { alert("Error : " + e.message); return; } </cfscript> <!---<cf_expenseList parentDiv="expenseListDiv" action="removeAll">---> <cfscript> angular.element("##angularDiv1").scope().$apply(function($scope){ $scope.expenses = []; }); </cfscript> </cffunction> <cfscript> //Attach receipt to expense item ---> function attachReceipt() { if (!createApplicationFolder()) return; var imageUrl = cfclient.camera.getPicture(); if (isDefined("imageUrl")) { var imagePath = copyFileFromTempToPersistentFileSystem(imageUrl); if (isDefined("imagePath") && imagePath != "") { _tmpImagePath = imagePath; console.log("image saved to " + imagePath); loadImage(imagePath,"receiptImg"); } } } //Creates application folder stored in variables.appFolderName ---> function createApplicationFolder() { //set persistent file system var persistentfileSystem = cfclient.file.setFileSystem("persistent"); var appFolderPath = persistentfileSystem.root.fullPath + "/" + variables.appFolderName; var createFolder = false; try { var dirPath = cfclient.file.getDirectory(appFolderPath); if (!isDefined("dirPath")) createFolder = true; } catch (any e) { //assume directory does not exist if (e.message) createFolder = true; } if (createFolder) { try { var dirEntry = cfclient.file.createDirectory(appFolderPath,true); } catch (any e) { alert("Error : " + appFolderPath + " - " + e.message); return false; } } return true; } //Copies image file from temporary file system to persistent file system //in the folder function copyFileFromTempToPersistentFileSystem (tempFilePath) { //save existing file system var oldFileSystem = cfclient.file.getFileSystem(); //Get file object from the path var tmpFile = cfclient.file.get(tempFilePath); //set persistent file system var persistentfileSystem = cfclient.file.setFileSystem("persistent"); //assume application folder is already created var newFilePath = persistentfileSystem.root.fullPath + "/" + variables.appFolderName + "/" + tmpFile.name; //If file with the same name exists in the persistent file system, then try save //it with different name var count = 1; while (count < 10) { try { cfclient.file.get(newFilePath); //file already exists. Try different file name count++; newFilePath = persistentfileSystem.root.fullPath + "/" + variables.appFolderName + "/" + replace(tmpFile.name,".","_") + "_" + count + ".jpg"; } catch (any e) { //Assume file does not exists. Go ahead and copy from temp location to persistent location. console.log("Exception : " + e.message); break; } } //Copy file cfclient.file.copy(tempFilePath,newFilePath); //remove temporary file cfclient.file.remove(tempFilePath); //Restore old file system cfclient.file.setFileSystem(oldFileSystem.name); //return new file path return newFilePath; } function loadImage (filePath, imageId) { //first try to load by using file path console.log("FilePath = " + filePath); var imageLoaded = false; var imageElement = angular.element("##" + imageId); console.log("Image scope - "); console.log(imageElement.scope()); imageElement.unbind("load"); imageElement.bind("load", function(event){ //image loaded successfully imageLoaded = true; }); imageElement.scope().$apply(function($scope){ $scope.imagePath = filePath; }); setTimeout(function() { if (imageLoaded) return; loadImageData (filePath, imageId, imageElement); },1000); } function loadImageData(filePath, imageId, imageElement) { var imageData = cfclient.file.readAsBase64(filePath); imageElement.scope().$apply(function($scope){ $scope.imagePath = imageData; }); } function deleteFile (filePath) { try { cfclient.file.remove(filePath); console.log("File " + filePath + " deleted"); } catch (any e) { console.log("Error removing file - " + filePath); } } </cfscript> </cfclient>
Look for angular.element text in the above code to know where AngularJS scopes are used to setting/retrieving data in/from controllers. Notice that we are assigning array of ExpenseVO CFCs to a variable in AngularJS scope –
<cfscript> angular.element("##angularDiv1").scope().$apply(function($scope){ $scope.expenses = expenses; }); </cfscript>
This works because CFCs are translated to JS object by cfclient.
CFCs are not changed from the previous applications. And expenseList.cfm is not needed now.
Download the entire source code. Download the Android APK file.
Because of recent changes in PhoneGap Build server, some of the file APIs may not work. This has been fixed post-beta builds of ColdFusion Thunder. If you are using the public beta build and want to package this application from source, then use this config.xml file.
-Ram Kulkarni
“Because of recent changes in PhoneGap Build server, some of the file APIs may not work.”
This sort of things worry us the most. When things like this break, we need to rely on bugbase, and fix usually comes in the next major release.
What’s Adobe commitment on making sure cfmobile will always work with latest ver of Phonegap? Or does it use a customized fork of phonegap that will not be synced with the latest phonegap update?
Rakshith, the product manager of ColdFusion, has said in many presentations that we are committed to make such fixes available as soon as possible.
And we have not customized PhoneGap for this feature. In fact when you package using PhoneGap Build, it is the PGB server that packages PhoneGap.