In Part 1, we looked at creating the Salesforce server application to expose the Approvals and the target objects via some REST based web services.
In the second part of this article we look at creating the Pebble watch app and hooking it up the web services.
Creating a connected Application
The first thing we need to do is create a connected application in Salesforce.
To do this, login to Salesforce and navigate to:
Setup->Create->Apps->Connected Apps and create a new App.
Give it an App Name, API Name and Contact Email.
Check the Enable OAuth Settings and enter a Callback URL of sfdc://success and finally make sure Access and manage your data (api) is in the Selected OAuth Scopes. Since you will be connecting via your mobile phone you should also set IP Relaxation to Relax IP restrictions.
Save your changes.
You’re going to need 2 pieces of information from the Connected App, the Consumer Key and Consumer Secret. For now just remember where to find them as you’ll need them later.
Creating the Pebble Watch App
If you havn’t already got one, you’ll need to sign up for a Pebble Cloud Developer Account at: https://cloudpebble.net/
Create a new Project of type: Pebble.js
You’re going to need a bunch of images. You’ll find all these in the GitHub at https://github.com/tscottdev/PebbleSFDCApprovals/tree/master/resources/images
Click the Add New button next to the Project Resources and upload them.
Next we’ll need to configure the Project a little. Click on the Settings menu.
Make sure the App Kind is set to Watchapp, the Menu Image is checkbox28x28.png and the Configurable checkbox is checked.
Adding the Configuration
In order to supply user credentials to Salesforce we are going to make the app configurable. This allows the user to configure the application via the Pebble companion app:
The user clicks the gear icon in the companion app and is presented with a configuration page. The configuration page is an html page hosted on your own web server. For this exercise I’ve hosted the html page in the public folder of my dropbox account and used the public URL to access it.
The html configuration page looks like this:
<!DOCTYPE html> <html> <head> <title>SFDC Approvals - Configuration</title> </head> <style> body { font-size: 12pt; color: white; background-color: black; } h1 { font-size: 14pt; } input { width: 100%; } </style> <script> // Get the Query Parameter function getQueryParam(variable, default_) { var query = location.search.substring(1); var vars = query.split('&'); for (var i = 0; i < vars.length; i++) { var pair = vars[i].split('='); if (pair[0] == variable) { return decodeURIComponent(pair[1]); } } return default_ || false; } function save() { var unElm = document.getElementById('un'); var pwElm = document.getElementById('pw'); var configuration = { un: unElm.value, pw: pwElm.value }; // Close config page and return data var return_to = getQueryParam('return_to', 'pebblejs://close#'); location.href = return_to + encodeURIComponent(JSON.stringify(configuration)); } </script> <body> <h1>SFDC Approvals - Configuration</h1> <form> User name:<br> <input type="text" name="un" id="un"> <br> User password:<br> <input type="password" name="pw" id="pw"> <br> <button type="button" onclick="save();">Save</button> </form> </body> </html>
The configuration page prompts for a username and password and passes it back to a local address as a JSON string. There’s some code in there to handle the call back address for the emulator as well as the companion app. We’ll see how we hook this up when we get onto the watch code.
Pebble Watch Code
First up we’ll create a new Javascript file called app-settings.js from within the CloudPebble IDE. We’ll use this to store a few constants, including the Salesforce App Client Id (Consumer Key) and Client Secret (Consumer Secret) from earlier. The main app will add an include to this file.
You’ll need to modify some of these values to suit your org:
/** * App Settings - You will need to replace this with values applicable to your org */ var appSettings = { // URL to the page hosting the Apps Config Screen configURL: 'https://dl.dropboxusercontent.com/u/XXXXXX/PebbleApps/SFDCApprovals/config-page.html', // The Client Id and Secret of your Connected App clientId: 'XXXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.XXXXXXXXXXXXXXXX.XXXXXXXXXXXXXXXX.XXXXXXXX', clientSecret: 'XXXXXXXXXXXXXXXXXXX', // The URL prefix for the REST bases service calls, amend your instance and namespace accordingly serviceURLprefix: 'https://na10.salesforce.com/services/apexrest/mynamespace/' }; this.exports = appSettings;
We’ll now create another Javascript file in the CloudPebble IDE called app.js. This is the main application code:
/** * (c) Tony Scott. Licensed under a Creative Commons Attribution 3.0 Unported License. * http://creativecommons.org/licenses/by/3.0/deed.en_US * * This software is provided as is, at your own risk and without warranty of any kind. * * Please visit my blog: https://meltedwires.com/ and 'like' if you've found this useful. */ // Required Libraries var UI = require('ui'); var ajax = require('ajax'); var Vector2 = require('vector2'); var Settings = require('settings'); var AppSettings = require('app-settings'); // Salesforce Token (returned by the login function) var token = ''; // Main Card var main = new UI.Card({ title: 'SFDC', subtitle: 'Approvals', icon: 'images/checkbox28x28.png', body: 'Connecting ...', action: { select: 'images/reload16x16.png' } }); // Add Event listners for Configuration Screen to get and store the User and Password Pebble.addEventListener('showConfiguration', function(e) { // Show config page console.log('AppSettings.configURL=' + AppSettings.configURL); Pebble.openURL(AppSettings.configURL); }); Pebble.addEventListener('webviewclosed', function(e) { console.log('Configuration window returned: ' + e.response); var configuration = JSON.parse(decodeURIComponent(e.response)); Settings.option('un', configuration.un); Settings.option('pw', configuration.pw); }); /** * Salesforce login function * * Arguments: success (function) Function to call upon successful login * fail (function) Function to call upon failed login */ function login(success, fail) { // OAuth login URL var url = 'https://login.salesforce.com/services/oauth2/token'; // Construct the body of the request var body = 'grant_type=password' + '&client_id=' + AppSettings.clientId + '&client_secret=' + AppSettings.clientSecret + '&username=' + Settings.option('un') + '&password=' + Settings.option('pw'); // Attempt the login and call the success or failiure function ajax({ url: url, method: 'post', type: 'text', data: body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, }, function(data) { // Success, store the token var obj = JSON.parse(data); token = obj.access_token; console.log('Login successful: ' + token); success(); }, function(error) { // Failure! var errorText = JSON.stringify(error); console.log('login failed: ' + errorText); fail(errorText); } ); } /** * Load the Approvals via the REST Sevice */ function loadApprovals() { // Indicate the status on the main screen main.body('Loading ...'); // Create the request var req = { Username: Settings.option('un'), }; console.log('req: ' + JSON.stringify(req)); // Call the REST service and handle the success or failure ajax({ url: AppSettings.serviceURLprefix + 'GetWorkItems/', method: 'post', type: 'json', data: req, headers: { 'content-type': 'application/json', 'Authorization' : 'Bearer ' + token }, }, function(res) { // Success, render the approvals console.log('res: ' + JSON.stringify(res)); main.body('Loaded.'); renderApprovals(res); }, function(error) { // Failure! var errorText = JSON.stringify(error); console.log('request failed: ' + errorText); main.body('Failed:\n' + errorText); main.action('select', 'images/reload16x16.png'); } ); } /** * Render the Approvals * * Arguments: res (object) JSON Response from the GetWorkItems REST Service */ function renderApprovals(res) { // If no work items, change the main window text and return if (res.WorkItems.length === 0) { main.body('Nothing to approve.'); return; } // Create an array of Menu Items var menuItems = []; // Create the Menu Items from the Work Items returned by the web service for (var i = 0; i < res.WorkItems.length; i++) { var menuItem = { title: res.WorkItems[i].ObjectName, subtitle: res.WorkItems[i].WorkItemName, id: res.WorkItems[i].WorkItemId, fields: res.WorkItems[i].Fields }; menuItems.push(menuItem); } console.log('menuItems=' + JSON.stringify(menuItems)); // Add the Menu Items to the menu var menu = new UI.Menu({ sections: [{ title: 'Approval List', items: menuItems }] }); // Add the on select event to show the Work Item in more detail menu.on('select', function(itemSelectEvent){ showWorkItem(itemSelectEvent); }); // Show the menu window menu.show(); } /** * Show the Work Item in more detail * * Arguments: itemSelectEvent (object) The menu select event */ function showWorkItem(itemSelectEvent) { // Get the menu item from the event var menuItem = itemSelectEvent.item; var bodyText = ''; // Build the card body from the fields returned. for (var i = 0; i < menuItem.fields.length; i++) { bodyText += menuItem.fields[i].Name + ': ' + menuItem.fields[i].Value + '\n'; } // There are several windows that will be created as the item // is approved, keep track of them to enable them to be removed var windowStack = []; // Create the Card var detailCard = new UI.Card({ title: menuItem.title, subtitle: menuItem.subtitle, body: bodyText, scrollable: true, action: { select: 'images/rightarrow12x16.png', up: 'images/uparrow16x11.png', down: 'images/downarrow16x11.png' } }); // Add it to the window stack windowStack.push(detailCard); // Add the event handler to proceed to the Approval or Rejection detailCard.on('click', 'select', function(){ approveOrReject(itemSelectEvent, windowStack); }); // Show the card detailCard.show(); } /** * Approve or Reject the Work Item * * Arguments: itemSelectEvent (object) The menu select event * windowStack (array) The Array of windows relating to this Work Item */ function approveOrReject(itemSelectEvent, windowStack) { // Create the Approve or Regject Card var approveOrRejectCard = new UI.Card({ title: 'Approve?', action: { up: 'images/approve16x16.png', down: 'images/reject16x16.png' } }); // Add it the stack windowStack.push(approveOrRejectCard); // Add the event handler for the Approval and Rejection approveOrRejectCard.on('click', 'up', function(){ approveItem(itemSelectEvent, true, windowStack); }); approveOrRejectCard.on('click', 'down', function(){ approveItem(itemSelectEvent, false, windowStack); }); // show the card approveOrRejectCard.show(); } /** * Perform the Approve or Reject * * Arguments: itemSelectEvent (object) The menu select event * approve (boolean) True to approve, false to reject * windowStack (array) The Array of windows relating to this Work Item */ function approveItem(itemSelectEvent, approve, windowStack) { // Get the menu item from the menu event var menuItem = itemSelectEvent.item; // Render the Approved/Rejected icon window var iconWindow = renderApproved(approve, windowStack); // Create the request, from the work item id and the approve/reject boolean var req = { WorkItemId: menuItem.id, Approve: approve }; console.log('req: ' + JSON.stringify(req)); // Call the REST service and handle the success or failure ajax({ url: AppSettings.serviceURLprefix + 'ApproveWorkItem/', method: 'post', type: 'json', data: req, headers: { 'content-type': 'application/json', 'Authorization' : 'Bearer ' + token }, }, function(res) { // Success console.log('res: ' + JSON.stringify(res)); // Call the function to clear the icon and hide all the windows in the stack iconWindow.hideAll(); // Remove the item form the menu itemSelectEvent.section.items.splice(itemSelectEvent.itemIndex, 1); // If no items left then hide the menu and change the main window text if (itemSelectEvent.section.items.length === 0) { main.body('Nothing to approve.'); itemSelectEvent.menu.hide(); return; } }, function(error) { // Failure! var errorText = JSON.stringify(error); console.log('request failed: ' + errorText); // Hide the icon window and render the error iconWindow.hide(); renderError(errorText); } ); } /** * Render the approved / rejected icon * * Arguments: approve (boolean) True to show the approve icon, false to show the reject icon * windowStack (array) The Array of windows relating to the Work Item * * Returns: (object) The created window */ function renderApproved(approve, windowStack) { // Create a new Window var win = new UI.Window(); // Add it to the stack windowStack.push(win); // Assign the image var img = approve ? 'images/approve128x128.png' : 'images/reject128x128.png'; // Create the image var image = new UI.Image({ position: new Vector2(8, 10), size: new Vector2(128, 128), backgroundColor: 'white', image: img, compositing: 'invert' }); // Add it to the window win.add(image); // Show the window win.show(); // Create the hideAll fucntion to remove it win.hideAll = function(){ // Animimate the image down var pos = image.position(); pos.y = 169; image.animate('position', pos, 300); // Queue up the removal of the window stack when the // animation completes image.queue(function(next) { hideWindowStack(windowStack); next(); }); }; // Return the window return win; } /** * Hide the window stack * * Arguments: windowStack (array) The Array of windows relating to the Work Item */ function hideWindowStack(windowStack) { while(windowStack.length > 0) { var win = windowStack.pop(); win.hide(); } } /** * Render an error * * Arguments: err (string) The error to render */ function renderError(err) { // Create the Card var errorCard = new UI.Card({ title: 'Failed', body: err, scrollable: true }); // Show the card errorCard.show(); } // Show the Main Card main.show(); // Login passing in success and failure functions login(function(res) { // Show connected status main.body('Connected.'); // Change the behaviour of the select button to load the approvals main.on('click', 'select', function() { loadApprovals(); }); loadApprovals(); }, function(errorText) { main.body('Failed:\n' + errorText); } );
You’ll see near the top we have a couple of calls to add event listeners for the Configuration screen. These are used to marshall the username and password to the app storage.
The login to Salesforce is done using a grant_type of password and therefore bypasses the OAuth flow. This isn’t ideal but we can’t supply a call-back URL to the OAuth flow and even if we could we’d have to make the user login via the config screen whenever the session expires.
Once the login is complete we store the token to use for subsequent API requests for retrieving the Work Items to Approve and Approving/Rejecting the Work Items.
The Pebble windowing system is very simple and takes a stack approach. When a window is loaded it pushes the window onto a stack. Clicking the back button removes the current window from the stack and the previous window is shown as does hiding it via the application code.
The flow of the app looks like this:
The Watch App source is available from my GitHub repo: https://github.com/tscottdev/PebbleSFDCApprovals
I’ll make the Server code available soon or you can cut and paste the code from Part 1.
I’m considering moving the Client Id and Client Secret to the Configuration Screen at some point so that I can publish the App and make it freely available on the Pebble App store. Watch this space, I hope you’ve found this useful.
Useful Links:
CloudPebble IDE: https://cloudpebble.net/ide/
Pebble Developer Portal: https://developer.getpebble.com/
Pebble Store: https://getpebble.com/