I recently bought a Pebble ‘smart’ watch and I have to say I’m pretty impressed with it. Rather than try and replace the phone in your pocket, it works to extend it by providing you with notifications and an app store full of simple intuitive apps and watch faces. Unlike certain devices I could mention, you get days worth of battery life from a single charge!
The UI is small and simple (144 x 168 pixels) and navigation is performed with just four buttons: Back (top left), Up, Down and Select. It uses a Low Energy bluetooth connection to your phone running a companion app.
For the geekier folk there is a developer portal and cloud based IDE for creating your own apps and watch faces and I couldn’t resist!
Developing apps comes in two flavours: C and Javascript. C apps take a little more work and run faster while Javascript apps are quick and easy to develop.
Naturally I wanted to try and hook up my watch to Salesforce.
In this series of blog posts I’m going to show you a Pebble watch application that communicates with Salesforce.
Use Case
For my application I decided to base it around a real world use case that I felt fitted the Pebble watch:
“As a manager I want to be able to quickly approve or reject requests on the go or in a meeting without having to reach for my phone or my laptop.”
The above use case is ideal for a Pebble app. It wants to be quick, intuitive with a minimum of clicks.
The following is a check list of requirements:
- I want to be notified when I have something to approve.
- I want to be able see what items I have to approve.
- I want to be able to see enough detail to make a decision.
- I want to be able to quickly approve or reject an item.
The first point is taken care of by the Pebble notification system. Since a Salesforce approval request generates and sends an email this will be automatically sent to the watch provided your phone is configured to show email notifications.
User Interface
Jumping ahead I want to show you the finished UI and flow before I describe how you get there:
To summarise the above flow:
- The app connects to Salesforce.
- It requests and loads the items to approve.
- It lists the items to approve.
- The user then selects an item to approve (via the middle select button)
- The user can then scroll up and down to view the detail of the item to approve.
- Clicking the middle select button takes them to the decision.
- Clicking ‘up’ approves and ‘down’ rejects and flow resumes from the list of items.
- Clicking the top left ‘back’ button returns to the previous screen.
- When no more items are left to approve the user is notified.
- Clicking the middle select button from the main screen will reload the items to approve.
Solution Overview
There are two parts to this solution, a small Salesforce server application and a PebbleJS client side application.
The premise behind the Pebble watch is that applications are small and light. For that reason we need to keep the client side application pretty thin. We could invoke the standard Salesforce APIs from the watch app but it makes far more sense to do any grunt work on the server.
So for the server application we have an object to configure what fields the user can see in the detail window of the watch and a couple of REST based web services to retrieve the approvals and approve/reject item.
The client application then consumes these web services and deals with the navigation and rendering of the data.
I decided to write the client app using the PebbleJS library for ease and speed of development. The watch app makes use of the Pebble Ajax library to connect to Salesforce via the phones internet connection.
Server Side Application
Within Salesforce I’ve created an object called ‘Pebble Approval Field’. This object is used to administer the fields which will be exposed to the watch app. The tab looks like this:
It simply provides a list of object API names, field API names and the Sort Order. There are a couple of hidden fields which are populated by a Trigger. These are the Objects 3 character Key Prefix, Object Label and Field Label. We’ll see how these are used later. The full structure of the object looks like this:
Singular Label: Pebble Approval Field
Plural Label: Pebble Approval Fields
API Name: Pebble_Approval_Field__c
Field Label | API Name | Type |
Field Id | Name | Autonumber |
Object API Name | Object_API_Name__c | Text(40) |
Field API Name | Field_API_Name__c | Text(40) |
Sort Order | Sort_Order__c | Number(3,0) |
Object Key Prefix | Object_Key_Prefix__c | Text(3) External Id |
Object Label | Object_Label__c | Text(40) |
Field Label | Field_Label__c | Text(40) |
The principal Approval objects we are going to use are the ProcessInstanceWorkItem and its parent object ProcessInstance.
The ProcessInstanceWorkItem gives us the outstanding Approvals for a particular user while its parent gives us the Id of the object being approved. As this is just an object Id we can grab its 3 character prefix and use this to query the Pebble Approval Fields via the Object Key Prefix field.
To populate this we have a trigger to do a describe of the object and retrieve this. Since we are doing a describe anyway it made sense to grab and populate the Object and Field labels and save the service from having to worry about doing this. Object and Field label changes are infrequent so it doesn’t feel too bad to populate this in the before trigger.
The trigger code looks like this:
/** * Pebble_Approval_Field__c trigger to validate API names and populate key prefix */ trigger PebbleApprovalFieldTrigger on Pebble_Approval_Field__c (before insert, before update) { // Set of System field Names Set<String> systemFields = new Set<String> {'CreatedById', 'CreatedDate', 'Id', 'IsDeleted', 'LastModifiedById', 'LastModifiedDate', 'Name', 'OwnerId', 'SystemModstamp'}; // Start with a Global Describe Map<String, Schema.SObjectType> globalDescribe = Schema.getGlobalDescribe(); Boolean hasErrors = false; List<String> validObjectNames = new List<String>(); // Validate the Object Names against the Describe for (Pebble_Approval_Field__c pebbleField : (List<Pebble_Approval_Field__c>)Trigger.new ) { if (globalDescribe.containsKey(pebbleField.Object_API_Name__c)) { validObjectNames.add(pebbleField.Object_API_Name__c); } else { pebbleField.Object_API_Name__c.addError('Object API name not found.'); hasErrors = true; } } // If there are any object errors, quit now if (hasErrors) { return; } // Describe for all the Valid Objects Schema.DescribeSobjectResult[] objectDescribes = Schema.describeSObjects(validObjectNames); // Map the Describes Results Map<String, Schema.DescribeSobjectResult> objectDescribeMap = new Map<String, Schema.DescribeSobjectResult>(); for (Schema.DescribeSobjectResult objectDescribe : objectDescribes) { objectDescribeMap.put(objectDescribe.getName(), objectDescribe); } // Validate the Field Names and populate the Key Prefix and Labels for (Pebble_Approval_Field__c pebbleField : (List<Pebble_Approval_Field__c>)Trigger.new ) { // First check the field has the same namespace prefix as the object (if applicable) String[] parts = pebbleField.Object_API_Name__c.split('__'); // Determine the Namespace String ns; if (parts.size() == 3) { ns = parts[0] + '__'; // If its not a system field, it should contain the namespace if (!systemFields.contains(pebbleField.Field_API_Name__c) && !pebbleField.Field_API_Name__c.startsWith(ns)) { pebbleField.Field_API_Name__c.addError('Field namespace is missing or does not match the object namespace.'); } } // Now get the object describe Schema.DescribeSobjectResult objectDescribe = objectDescribeMap.get(pebbleField.Object_API_Name__c); Map<String, Schema.SObjectField> fieldMap = objectDescribe.fields.getMap(); // Strip the Namespace from the Field Name before checking the field map String fieldName = ns == null ? pebbleField.Field_API_Name__c : pebbleField.Field_API_Name__c.removeStart(ns); if (!fieldMap.containsKey(fieldName)) { pebbleField.Field_API_Name__c.addError('Field API name not found.'); } // Populate the key prefix and labels Schema.DescribeFieldResult fieldDescribe = fieldMap.get(fieldName).getDescribe(); pebbleField.Object_Key_Prefix__c = objectDescribe.getKeyPrefix(); pebbleField.Object_Label__c = objectDescribe.getLabel(); pebbleField.Field_Label__c = fieldDescribe.getLabel(); } }
Get Work Items REST Web Service
The first web service is used to retrieve a list of Work Items to approve for a given user. It does this by querying the ProcessInstance/ProcessInstanceWorkItem object and the target objects data into an easy to consume response.
The request simply takes a username and the response returns a list of work items. Each work item is accompanied by a list of field labels and values.
Here’s the code:
/** * REST based web service to Get Work Items */ @RestResource(urlMapping='/GetWorkItems/*') global without sharing class GetWorkItemsRESTService { /** * Service Entry point */ @HttpPost global static void doPost() { RestRequest restReq = RestContext.request; RestResponse restRes = RestContext.response; // JSON parse the body into a Request object Request req = (Request)JSON.deserialize(restReq.requestBody.toString(), Request.class); // Process the Request Response res = processRequest(req); // Serialize the Response restRes.responseBody = Blob.valueOf(JSON.serialize(res)); } /** * Process the Request */ private static Response processRequest(Request req) { // Get the user record User usr = findUserByUsername(req.Username); // get the work items and the target objects List<ProcessInstanceWorkitem> workItems = findWorkItemsByUserId(usr.Id); // Build a Map of Key Prefixes to Sets of Object Ids Map<String, Set<Id>> prefixIdMap = new Map<String, Set<Id>>(); for (ProcessInstanceWorkitem item : workItems) { String keyPrefix = ('' + item.ProcessInstance.TargetObjectId).left(3); Set<Id> ids = prefixIdMap.get(keyPrefix); if (ids == null) { ids = new Set<Id>(); prefixIdMap.put(keyPrefix, ids); } ids.add(item.ProcessInstance.TargetObjectId); } // Map the Pebble Approval fields by the SObject key prefixes Map<String, List<Pebble_Approval_Field__c>> fieldMap = mapPebbleApprovalFieldsByKeyPrefix(prefixIdMap.keySet()); // Query and Map the target objects using the maps Map<Id, SObject> idMap = mapObjects(prefixIdMap, fieldMap); // build the response and return Response res = new Response(); // Loop throught the Work Items for (ProcessInstanceWorkitem item : workItems) { // Get the Target Object from the Map SObject so = idMap.get(item.ProcessInstance.TargetObjectId); // if not found, skip as we can only pass back objects that have been mapped if (so == null) { continue; } // Get the key prefix String keyPrefix = ('' + item.ProcessInstance.TargetObjectId).left(3); // Get the fields from the Map List<Pebble_Approval_Field__c> fields = fieldMap.get(keyPrefix); // Create new Work Item to add to the response WorkItem wi = new WorkItem(item.Id, so.Id, fields[0].Object_Label__c, (String)so.get('Name')); for (Pebble_Approval_Field__c f : fields) { Field field = new Field(f.Field_Label__c, '' + formatObject(so.get(f.Field_API_Name__c))); wi.Fields.add(field); } res.WorkItems.add(wi); } return res; } /** * Find the User record by the Username */ private static User findUserByUsername(String username) { try { return [Select Id From User Where Username = :username]; } catch(System.QueryException e) { String error = 'User {0} not found.'; throw new GetWorkItemsException(String.format(error, new String[] { username })); } } /** * Get a list of Work Items by the User Id */ private static ProcessInstanceWorkitem[] findWorkItemsByUserId(Id userId) { return [Select Id, ActorId, CreatedDate, ProcessInstanceId, ProcessInstance.TargetObjectId From ProcessInstanceWorkitem Where ActorId = :userId Order By CreatedDate desc]; } /** * Load and map the Pebble Approval fields according to their Objects key prefix */ private static Map<String, List<Pebble_Approval_Field__c>> mapPebbleApprovalFieldsByKeyPrefix(Set<String> keyPrefixes) { Map<String, List<Pebble_Approval_Field__c>> rtnMap = new Map<String, List<Pebble_Approval_Field__c>>(); for (Pebble_Approval_Field__c f : [Select Id, Name, Object_API_Name__c, Field_API_Name__c, Object_Key_Prefix__c, Sort_Order__c, Object_Label__c, Field_Label__c From Pebble_Approval_Field__c Where Object_Key_Prefix__c in :keyPrefixes Order By Object_API_Name__c, Sort_Order__c]) { List<Pebble_Approval_Field__c> fields = rtnMap.get(f.Object_Key_Prefix__c); if (fields == null) { fields = new List<Pebble_Approval_Field__c>(); rtnMap.put(f.Object_Key_Prefix__c, fields); } fields.add(f); } return rtnMap; } /** * Load and map the target object records by their Id */ private static Map<Id, SObject> mapObjects(Map<String, Set<Id>> prefixIdMap, Map<String, List<Pebble_Approval_Field__c>> fieldMap) { Map<Id, SObject> rtnMap = new Map<Id, SObject>(); // Loop through the key prefixes for (String keyPrefix : prefixIdMap.keySet()) { // Get the fields for the prefix List<Pebble_Approval_Field__c> fields = fieldMap.get(keyPrefix); // if we have no fields, skip if (fields == null || fields.isEmpty()) { continue; } // Query the objects and add them to the map rtnMap.putAll(query(fields, prefixIdMap.get(keyPrefix))); } return rtnMap; } /** * Query the object using the field map for the supplied ids */ private static Map<Id, SObject> query(List<Pebble_Approval_Field__c> fields, Set<Id> objectIds) { // Build a Set of Fields (always include the Id and the Name) Set<String> fieldSet = new Set<String>{ 'Id', 'Name' }; for (Pebble_Approval_Field__c f : fields) { fieldSet.add(f.Field_API_Name__c); } // Build the Query according to the fields String query = 'Select '; for (String f : fieldSet) { query += f + ','; } // Strip the trailing , query = query.removeEnd(','); query += ' From ' + fields[0].Object_API_Name__c + ' Where Id in :objectIds'; return new Map<Id, SObject>(Database.query(query)); } /** * Perform some rudimentary formatting depending on the objects type */ private static Object formatObject(Object o) { if (o == null) { return ''; } if (o instanceOf Date) { return ((Date)o).format(); } else if (o instanceOf DateTime) { return ((DateTime)o).format(); } else if (o instanceOf Decimal) { return ((Decimal)o).format(); } return o; } /** * Inner class representing the Request */ private class Request { public String Username {get; set;} } /** * Inner class representing the Response */ private class Response { public List<WorkItem> WorkItems {get; set;} public Response() { WorkItems = new List<WorkItem>(); } } /** * Inner class representing a Work Item */ private class WorkItem { public Id WorkItemId {get; set;} public Id ObjectId {get; set;} public String WorkItemName {get; set;} public String ObjectName {get; set;} public List<Field> Fields {get; set;} public WorkItem(Id workItemId, Id objectId, String objectName, String workItemName) { this.WorkItemId = workItemId; this.ObjectId = objectId; this.ObjectName = objectName; this.WorkItemName = workItemName; this.Fields = new List<Field>(); } } /** * Inner class representing a Field label and value */ private class Field { public String Name {get; set;} public String Value {get; set;} public Field(String name, String value) { this.Name = name; this.Value = value; } } /** * Exception class */ private class GetWorkItemsException extends Exception {} }
Approve Work Items REST Web Service
The second of our web services is to carry out the approval or rejection process on a single work item.
The request takes the Id of the work item and a boolean to indicate whether to approve or reject. The response simply returns a success indicator in the form of a boolean.
Here’s the code:
/** * Rest based service to Get Work Items */ @RestResource(urlMapping='/ApproveWorkItem/*') global with sharing class ApproveWorkItemRESTService { private static final String STATUS_APPROVED = 'Approved'; private static final String STATUS_REJECTED = 'Rejected'; /** * Service Entry point */ @HttpPost global static void doPost() { RestRequest restReq = RestContext.request; RestResponse restRes = RestContext.response; // JSON parse the body into a Request object Request req = (Request)JSON.deserialize(restReq.requestBody.toString(), Request.class); // Process the Request Response res = processRequest(req); // Serialize the Response restRes.responseBody = Blob.valueOf(JSON.serialize(res)); } /** * Process the Request */ private static Response processRequest(Request req) { // Create a Process Work item Request Approval.ProcessWorkitemRequest processReq = new Approval.ProcessWorkitemRequest(); processReq.setWorkitemId(req.WorkItemId); if (req.Approve) { processReq.setComments('Approved.'); processReq.setAction('Approve'); } else { processReq.setComments('Rejected.'); processReq.setAction('Reject'); } // Submit the request for approval Approval.ProcessResult processResult = Approval.process(processReq); // If it failed, capture the errors if (!processResult.isSuccess()) { String errors = ''; for (Database.Error err : processResult.getErrors()) { errors += err.getMessage() + '/n'; } throw new ApproveWorkItemException(errors); } return new Response(true); } /** * Inner class representing the Request */ private class Request { public Id WorkItemId {get; set;} public Boolean Approve {get; set;} } /** * Inner class representing the Response */ private class Response { public Boolean Success {get; set;} public Response(Boolean success) { this.Success = success; } } /** * Exception class */ private class ApproveWorkItemException extends Exception {} }
That’s all for now …
In the second part of this post I’ll show you the Pebble watch app and how to connect it to the REST based web services.
Great Post Tony! You are awesome!