Sunday, 1 December 2013

Dreamforce 2013: A developer diary

Last week I was lucky enough to fly over to San Francisco and attend Dreamforce 2013. This was my third consecutive year attending the event, and was the biggest, best and most inspiring yet. Here is a diary of my experience...

Sunday


Despite the fact that Dreamforce did not start officially until Monday, that was not going to halt the community from kicking things off early. Postcode Anywhere hosted the networking event Ale Anywhere, a chance for delegates to get together before the event and discuss the upcoming conference. This was a great night, heavily attended by UK representatives (hence the ale). I'm sure that next year the event will be running again, which I would really recommend for any UK delegates looking to acclimatise themselves before the conference. In the mean time, check out Postcode Anywhere's key DF takeaways .

Monday


When attending Dreamforce in previous years, I have always had a tendency to hit the ground running, and this year was no exception. Not long after the massive devzone was officially opened in the Moscone West, I presented in a session entitled "I passed the Force.com Advanced Developer Certification". It was a panel session including Peter Chittum, Leah McGowan-Hare and Barry Hughes. The aim of the session was to show the varied personal experiences of developers who have successfully passed the certification, and the positive and negative experiences throughout the process. I really enjoyed taking part in the session, I felt that it represented the human element of the certification experience really well.

here is the session recording:


 


If you're interested in more sessions about certifications, there is a similar session for the Technical Architect Certification

After my session, I spent the majority of the day exploring the devzone, visiting partner stalls, attending some cool sessions and getting hands on with some practical exercises. The main difference between the devzone this year and previous years was the emphasis on connected devices in the new "connected devices lab" area. This area was set up to demonstrate how the data we hold in cloud applications can be generated by sensors and be used in turn to affect connected systems physically. All kinds of systems were on display, from large scale 3D printing to a digital piano with bananas and pears as keys.

Tuesday

Tuesday morning was all about one thing, the Dreamforce keynote with Marc Benioff and special guests. As per usual, the keynote was full of inspiring technology and a view of the present and future of Salesforce. The keynote opened with the companies philanthropic efforts, which was a nice touch personally as I am a massive fan of the 1-1-1 model Salesforce endorses. The keynote included a lot of cool demos, not least using Google Glass to update a support case in real time, check it out below, I challenge you not to be inspired.




This year's big announcement was the introduction of the Salesforce 1 Platform. The 1 platform is a culmination and enhancement of all of existing Salesforce functionality into a full mobile ready crm platform. This was a welcome announcement, not least because it was available that day, right then. This wasn't merely a forward looking statement, it was all live now, already my chatter mobile app had been replaced with the new 1 platform mobile app, which included all of my org standard objects and apps. The whole mobile experience for users is now a lot more complete and slick, if you have an org and android/iphone, I suggest you download the mobile app ASAP. 

After the keynote, fully charged with awesome enthusiasm for all things Salesforce and Force.com, I returned to the devzone to take part in some developer mini-hacks. The mini-hacks are a set of coding and development challenges designed to get developers started with some force.com features they may not have experienced before and some cool partner apps.

After a long day of hacking, I managed to finish 4 of the partner hack challenges, set by Docusign, Skuid, HP and Twilio. I particularly enjoyed the Docusign and Twilio challenges, which were about electronically signing important documents from Salesforce objects and sending text messages directly from a Salesforce page respectively. Even better, because I was one of the first 10 developers to finish the challenges, I got presented with some beats audio headphones and a hoodie by the Docusign stand.




Congrats to fellow Desynit developer Simon Lawrence (check out his blog), who was also one of the first ten to complete the challenges!

I finished the day by going to the Dreamforce Gala, which included performances by Blondie and a slightly damp Green Day :) , as well as free beer and all the hotdogs you could ever want. It's a hard life for a dev sometimes ;) .

Wednesday


First call on the third day of Dreamforce was the developer keynote session. The top headline for me was that the total number of Force.com developers had nearly doubled in the past year, jumping from 800,000 to over 1.4 million! Wow that's a big jump, but even so the demand for new Force devs is still higher than ever.

The keynote included a demo of the Command-line Interface tool for force.com, a command line tool for configuring force.com environments. I must admit I was a little skeptical at first, but once I saw the ease of which a new org was set up and field values were added, I was sold, well worth a look.

After the keynote, I ventured to the other end of the conference to attend the "Listen up Marketing! The User Group Program has some valuable lessons to share" session hosted by Desynit's very own Amy Grenham and Matthew Morris. Desynit have had a very active role in the user group program, and this was a great opportunity for us to show the lessons we have learned, and how users groups can adapt to their local environment to make sure attendees get the most out of events. If you are involved in a user group, or have ever contemplated running / attending an event, then this is the session for you.




After some lunch, I returned back to the developer zone, for some further exploration, experimentation and sessions. One of the real highlights was a session titled "The Apex Ten Commandments", hosted by Francis Pindar and Kevin Poorman. The purpose of the session was to outline 10 core best practices for developing on the Force.com platform. This is a great session for new and experienced developers alike, if we could all follow these rules, our own and fellow developer's lives would all be made a lot easier.



Thursday


It was the last day of the conference, but everyone was still buzzing. I started the day by taking one last tour of the devzone and the cloud expos. I had already been at the conference for 3 full days, but still had the feeling I had hardly scratched the surface. I saw a few demonstrations of sensor / actuator tech from Reid Carlberg, who was starting to lose his voice after three solid days of high energy demos (the man is a machine).

Throughout the conference, a $1,000,000 hackathon challenge had been running. One of the last sessions to start in the devzone was a demonstration of the five shortlisted apps to win the grand prize. The range of entries was inspiring, and although I was dubious about the degree to which the solutions had be "hacked", I couldn't help but be impressed by the quality and usability of the varied solutions. The entry that particularly impressed, upshot (the eventual winner), was a natural language instant report generator for Salesforce users on the go. It was simple in terms of development components and interface, but when combined made a simple incredibly usable and effective app. Another noteworthy finalist was Salesfetch, a lead history app that showed the entire interactions the business has had with an individual, not least because it was produced by four French students who had no force.com experience prior to that week.

Friday


I made my way back to the UK having fully enjoyed and been inspired by Dreamforce like never before. The introduction of the 1 platform and the move towards a more connected world of devices and customers were the key elements of the conference, and provide devs with more than enough to get their teeth into for a long time to come, well until at least DF14 anyway ;)

Sunday, 15 September 2013

Clone Plus Test Method Example

Well, it's been a while! I have been very busy, getting qualified as a certified advanced developer (see shiny new badge on the right hand bar :-o), sorting out my upcoming Dreamforce visit, reviewing Force.com books and promoting the South West Force.com group (more posts to come on these topics soon!).

Anyway, I figured a visit to clone plus was well overdue. For those unfamiliar with clone plus and what it does, see these previous posts introducing clone plus and some refinements that have been made along the way. The posts have proved quite popular, with lots of people providing feedback and asking questions. One of the most common questions that has appeared on the topic is test coverage. As is well known, all apex code needs at least 75% coverage to be promoted to a production org, and clone plus is no exception.

Below is a sample test class for clone plus, alongside the latest implementation of the page and controller (I have made some tweaks, mainly error handling). This is just an example of how you might go about testing a class like the clone plus controller. It is by no means an absolute complete testing solution, as it does not cover every scenario the user could find themselves in while using the controller.

Latest Version of Clone Plus Controller:
public class ClonePlusController {

  public List<relatedObjects> objectChildren  { get; set; }
  public String               objectTypeName  { get; set; }
  public String               objectName      { get; set; }
   
  @TestVisible private SObject headSObject, headClone;
  
  // Initialisation method called when the clone plus page is first loaded.
  // Use the id page parameter to find out what kind of object we are trying to clone.
  // Then load the object from the database.
  // Finally call the populateObjectChildren method to      
  public void initialiseObjectsForCloning()
  {

    // Here we generate a keyprefixmap using the global describe 
    // Then compare that to our object to determine type.  
    Map<String, Schema.SObjectType> gd = Schema.getGlobalDescribe(); 
       
    Map<String,String> keyPrefixMap = new Map<String,String>{};
          
    for(String sObj : gd.keySet()){
      Schema.DescribeSObjectResult r =  gd.get(sObj).getDescribe();
      keyPrefixMap.put(r.getKeyPrefix(), r.getName());
    }
      
    String objectID                 = ApexPages.currentPage().getParameters().get('id');
    String objectTypeKey            = objectId.subString(0,3);
      
    objectTypeName                  = keyPrefixMap.get(objectTypeKey);
      
    String primaryObjectQueryString = 'SELECT Id, Name FROM '
                                    + objectTypeName
                                    + ' WHERE Id = \''
                                    + objectId
                                    + '\'';
    
    headSObject = Database.query(primaryObjectQueryString);
    objectName          = '' + headSObject.get('Name');
    populateObjectChildren();    
  }

  // Get all of the children of the current object that have a object type
  // contained in the child object types page parameter.
  // Not restricting the child objects to particular types results in many
  // unclonable system objects being added to the possibiilites, which we need to avoid.
  // Making these object type choices also allows us 
  // pick and chose the specific kinds of objects we want to allow users to clone.  
  public void populateObjectChildren()
  {
       
    objectChildren = new List<relatedObjects>{};
        
    Set<String> childObjectTypes = new Set<String>{};
    
    // read the object types from the page parameter.    
    childObjectTypes.addAll(
         ApexPages.currentPage().getParameters().get('childobjecttypes').split(',')
    );
    
    // Use the sobjecttype describe method to retrieve all 
    // child relationships for the object to be cloned.    
    Schema.DescribeSObjectResult headDescribe = 
           headsObject.getSObjectType().getDescribe();
    
    List<Schema.ChildRelationship> childRelationships = 
           headDescribe.getChildRelationships(); 
    
    // Iterate through each relationship, and retrieve the related objects.       
    for (Schema.ChildRelationship childRelationship : childRelationships)
    {
      Schema.SObjectType childObjectType = childRelationship.getChildSObject();
      
      // Only retrieve the objects if their type is one of those we are interested in.          
      if (childObjectTypes.contains(childObjectType.getDescribe().getName()))
      {
        List<relatedObjectRow> relatedObjects = new List<relatedObjectRow>{};
                
        Schema.SObjectField childObjectField = childRelationship.getField();
                
        String relatedChildSObjectsquery = 'SELECT ID FROM ' 
                                         + childObjectType.getDescribe().getName()
                                         + ' WHERE '
                                         + childObjectField.getDescribe().getName()
                                         + ' = \'' 
                                         + headsObject.Id
                                         + '\''; 
                                                        
        for (SObject childObject : Database.query(relatedChildSObjectsquery))
        {
          relatedObjects.add(new relatedObjectRow(childObject));
        }
            
        if (!relatedObjects.isEmpty())
        {
          objectChildren.add(new relatedObjects(relatedObjects, 
                             childObjectType.getDescribe().getLabelPlural(), 
                             childObjectField.getDescribe().getName()));
        }  
      }
    }
  }
  
  // Perform the cloning process.
  // First clone the parent, then all of the child objects. 
  // Then redirect the user to the new object page.
  public PageReference doClone()
  {
   try
   {
      headClone = cloneObjects(new List<sObject>{headSObject})[0];
      insert headClone;
    
      cloneSelectedObjects();
    
      return new PageReference('/' + headClone.Id);
    }
    // If there is any kind of exception, then put a page message on the screen to inform the user.
    catch (exception e)
    {
      ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, e.getMessage()));
      return null;
    }
  }
  
  // Clone the selected child objects.
  // Associate the cloned objects with the new cloned parent object.
  public void cloneSelectedObjects()
  {
        
    List<sObject> clonedObjects = new List<sObject>{};
    List<sObject> selectedRelatedObjects;
     
    for (relatedObjects relatedObject : objectChildren)
    {
      selectedRelatedObjects = new List<sObject>{};  
      clonedObjects = new List<sObject>{};  
      
      for (relatedObjectRow row : relatedObject.objectRows) 
      {
        if (row.selected)
        {
          selectedRelatedObjects.add(row.obj);
        }
      }
      
      if (!selectedRelatedObjects.isEmpty())
      {
        clonedObjects = cloneObjects(selectedRelatedObjects);
        
        for (sObject clone : clonedObjects)
        {
          clone.put(relatedObject.relatedFieldName, headClone.Id);  
        }
        
        insert clonedObjects;
      }
    }
  }

  // Clone a list of objects to a particular object type
  // Parameters 
  // - List<sObject> sObjects - the list of objects to be cloned 
  // The sObjects you pass in must include the ID field, 
  // and the object must exist already in the database, 
  // otherwise the method will not work.
  public static List<sObject> cloneObjects(List<sObject> sObjects){
                                                
    Schema.SObjectType objectType = sObjects.get(0).getSObjectType();
    
    // A list of IDs representing the objects to clone
    List<Id> sObjectIds = new List<Id>{};
    // A list of fields for the sObject being cloned
    List<String> sObjectFields = new List<String>{};
    // A list of new cloned sObjects
    List<sObject> clonedSObjects = new List<sObject>{};
    
    // Get all the fields from the selected object type using 
    // the get describe method on the object type.    
    if(objectType != null)
    {
      for (Schema.SObjectField objField : 
           objectType.getDescribe().fields.getMap().values())
      { 
        Schema.DescribeFieldResult fieldDesc = objField.getDescribe();
        // If the field type is location, then do not include it,
        // otherwise it will cause a soql exception.
        // Note that excluding the field does not stop the location from
        // being copied to the new cloned object.
        if(fieldDesc.getType() != DisplayType.LOCATION)
        {
          sObjectFields.add(fieldDesc.Name);
        }
      }
    }
    
    // If there are no objects sent into the method, 
    // then return an empty list
    if (sObjects != null || 
        sObjects.isEmpty() || 
        sObjectFields.isEmpty()){
    
      // Strip down the objects to just a list of Ids.
      for (sObject objectInstance: sObjects){
        sObjectIds.add(objectInstance.Id);
      }

      /* Using the list of sObject IDs and the object type, 
         we can construct a string based SOQL query 
         to retrieve the field values of all the objects.*/
    
      String allSObjectFieldsQuery = 'SELECT ' + String.join(sObjectFields,','); 
    
      allSObjectFieldsQuery += ' FROM ' + 
                               objectType.getDescribe().getName() + 
                               ' WHERE ID IN (\'' + sObjectIds.get(0) + 
                               '\'';

      for (Integer i=1 ; i < sObjectIds.size() ; i++){
        allSObjectFieldsQuery += ', \'' + sObjectIds.get(i) + '\'';
      }
    
      allSObjectFieldsQuery += ')';
    
      // Execute the query. For every result returned, 
      // use the clone method on the generic sObject 
      // and add to the collection of cloned objects
      for (SObject sObjectFromDatabase:
           Database.query(allSObjectFieldsQuery))
      {
        clonedSObjects.add(sObjectFromDatabase.clone(false,true));  
      }

    }
   
    return clonedSObjects;
    
  }
  
  // Related objects data construct - used to store a collection of child objects
  // connected to the head object through the same relationship field.
  public class relatedObjects
  {
    public List<relatedObjectRow> objectRows       { get; set; }
    public String                 pluralLabel      { get; set; }
    public String                 relatedFieldName { get; set; }
    
    public relatedObjects(List<relatedObjectRow> objectRows, 
                          String pluralLabel, 
                          String relatedFieldName) 
    {
      this.objectRows       = objectRows;
      this.pluralLabel      = pluralLabel;
      this.relatedFieldName = relatedFieldName;
    }   
  }     

  // An indidual child object row. It simple contains the object definition, 
  // and a checkbox to select the row for cloning on the clone plus page.
  public class relatedObjectRow
  {
    public sObject obj      { get; set; }
    public Boolean selected { get; set; }
    
    public relatedObjectRow(Sobject obj)
    {
      this.obj      = obj;
      // All object rows are selected by default.
      this.selected     = true;
    }
    
    public String getId(){
      try{
        return '' + obj.get('Id');
      } catch (Exception e){
        return '';
      }    
    }
  }
}


Latest Version of Clone Plus Page Code:
<apex:page controller="ClonePlusController" action="{!initialiseObjectsForCloning}">
    
  <apex:sectionHeader title="Clone Plus: {!objectName}"/>

  <apex:pageMessages />

  <apex:form id="theform" >
  
    Please select the child objects you would like to clone.<br/><br/>
  
    <apex:repeat value="{!objectChildren}" var="child">
      <apex:PageBlock title="{!child.pluralLabel}"> 
        <apex:pageBlockTable value="{!child.objectRows}" var="objectRow">
          <apex:column headerValue="Clone" width="10%">
            <apex:inputCheckbox value="{!objectRow.selected}"/>
          </apex:column>
       <apex:column headerValue="Id"   value="{!objectRow.Id}" width="45%"/>
        </apex:pageBlockTable>
      </apex:PageBlock>
    </apex:repeat>
    
    <apex:PageBlock >
      <apex:commandButton action="{!doClone}" value="Clone"/>
    </apex:PageBlock>
  </apex:form>  

</apex:page>


Sample Test Class:
/*
    A test method for the clone plus controller.
*/

@isTest
public class ClonePlusControllerTest
{
    /*
        Test the initialise objects for cloning method.
        
        Initialise an instance of the clone plus controller, and add some test parameters to the test URL.
        
        In this test, we will add an account Id as the the object to be cloned.
    */
    private static testmethod void testInitialiseObjectsForCloning()
    {
        // First lets create our sample Account object and some related Contacts.
        Account acc = new Account (Name = 'Test Account', Phone = '01482 123456789'); 
        insert acc;
        
        List<Contact> cons = new List<Contact>{};
        cons.add(new Contact(AccountId = acc.Id, FirstName = 'Sam', 
                             LastName = 'Ple' , email='test1@salesforce.com'));
        cons.add(new Contact(AccountId = acc.Id, FirstName = 'Ted', 
                             LastName = 'Test', email='test2@salesforce.com'));
        insert cons;
        
        List<Opportunity> opps = new List<Opportunity>{};
        opps.add(new Opportunity(AccountId = acc.Id, Name = 'Test Opportunity 1', 
                                 StageName='Closed Won' , CloseDate=Date.TODAY()));
        opps.add(new Opportunity(AccountId = acc.Id, Name = 'Test Opportunity 2', 
                                 StageName='Closed Lost', CloseDate=Date.TODAY()));
        insert opps;
        
        // Put the newly created account Id as the Id parameter on the page.
        ApexPages.currentPage().getParameters().put('Id', acc.id);
        
        // Also, add the contact object as a child object to clone.
        ApexPages.currentPage().getParameters().put('childobjecttypes','Contact,Opportunity');
        
        // Initialise a test instance of a clone plus controller.
        ClonePlusController cpc = new ClonePlusController();
        
        // Now call the initialise method.
        cpc.initialiseObjectsForCloning();
        
        // Now verify that all the class variables have been correctly set.
        system.assertEquals(2              , cpc.objectChildren.size());
        system.assertEquals('Contacts'     , cpc.objectChildren[0].pluralLabel);
        system.assertEquals('AccountId'    , cpc.objectChildren[0].relatedFieldName);
        system.assertEquals('Opportunities', cpc.objectChildren[1].pluralLabel);
        system.assertEquals('AccountId'    , cpc.objectChildren[1].relatedFieldName);
        
        // The related contacts should all be loaded into the controller.
        system.assertEquals(2              , cpc.objectChildren[0].objectRows.size());
        system.assertEquals(cons[0].Id     , cpc.objectChildren[0].objectRows[0].getId());
        system.assertEquals(cons[1].Id     , cpc.objectChildren[0].objectRows[1].getId());
        
        // The related opportunites should all be loaded into the controller.
        system.assertEquals(2              , cpc.objectChildren[1].objectRows.size());
        system.assertEquals(opps[0].Id     , cpc.objectChildren[1].objectRows[0].getId());
        system.assertEquals(opps[1].Id     , cpc.objectChildren[1].objectRows[1].getId());
        
        // All related objects should be selected for clonign by default.
        system.assert(cpc.objectChildren[0].objectRows[0].selected);
        system.assert(cpc.objectChildren[0].objectRows[1].selected);
        system.assert(cpc.objectChildren[1].objectRows[0].selected);
        system.assert(cpc.objectChildren[1].objectRows[1].selected);        

  // These variables all store information about the head object (Account)
        system.assertEquals('Account'   , cpc.objectTypeName);
        system.assertEquals(acc.Name    , cpc.objectName);
        system.assertEquals(acc.Id      , cpc.headSObject.Id);
        system.assertEquals(null        , cpc.headClone);
    }
    
    /*
     Test the "doClone" method. This method clones the parent object held in the controller,
     and all the related objects that have also been selected for copying.

        Initialise an instance of the clone plus controller, and populate the class variables with
        some sample objects. Select some of the related objects for cloning and ignore some others
  
        After the method has run, query the database to make sure that only those objects selected
        for cloning have been replicated.
    */
    private static testmethod void testDoCloneSuccess()
    {
        // First lets create our sample account object and some related Accounts.
        Account acc = new Account (Name = 'Test Account', Phone = '01482 123456789'); 
        insert acc;
        
        List<Contact> cons = new List<Contact>{};
        cons.add(new Contact(AccountId = acc.Id, FirstName = 'Sam', 
                             LastName = 'Ple', email='test1@salesforce.com'));
        cons.add(new Contact(AccountId = acc.Id, FirstName = 'Ted', 
                             LastName = 'Test', email='test2@salesforce.com'));
        insert cons;
        
        List<Opportunity> opps = new List<Opportunity>{};
        opps.add(new Opportunity(AccountId = acc.Id, Name = 'Test Opportunity 1', 
                                 StageName='Closed Won' , CloseDate=Date.TODAY()));
        opps.add(new Opportunity(AccountId = acc.Id, Name = 'Test Opportunity 2', 
                                 StageName='Closed Lost', CloseDate=Date.TODAY()));
        insert opps;        
        
        // Initialise a test instance of a clone plus controller.
        ClonePlusController cpc = new ClonePlusController();
        
        // Populate the controller variables as if the Account created above had been 
        // selected for cloning along with all its related contacts.
        cpc.headSobject    = acc;
        
        List<ClonePlusController.RelatedObjectRow> relatedContactRows 
                               = new List<ClonePlusController.RelatedObjectRow>{};
        relatedContactRows.add(new ClonePlusController.RelatedObjectRow(cons[0]));
        relatedContactRows.add(new ClonePlusController.RelatedObjectRow(cons[1])); 
        
        List<ClonePlusController.RelatedObjectRow> relatedOpportunityRows 
                               = new List<ClonePlusController.RelatedObjectRow>{};
        relatedOpportunityRows.add(new ClonePlusController.RelatedObjectRow(opps[0]));
        relatedOpportunityRows.add(new ClonePlusController.RelatedObjectRow(opps[1]));
        
        // Deselect one of the Opportunities, indicating that it should not be cloned.       
        relatedOpportunityRows[0].selected = false;
        
        cpc.objectChildren = new List<ClonePlusController.RelatedObjects>{};
        cpc.objectChildren.add(new ClonePlusController.RelatedObjects(relatedContactRows,    
                                                                      'Contacts', 'AccountId'));
        cpc.objectChildren.add(new ClonePlusController.RelatedObjects(relatedOpportunityRows,
                                                                      'Opportunity', 'AccountId'));
        
        // Call the do clone method. Record the page reference passed in response. 
        // We will use this later in the test to ensure that the user is correctly being 
        // directed to the new cloned object detail page.
        PageReference doCloneResult = cpc.doClone();
        
        // Check the database for the presence of the newly cloned records.
        List<Account> clonedAccs = [SELECT Name, Phone,
                                          (SELECT FirstName, LastName, Email
                                           FROM Contacts
                                           ORDER By FirstName),
                                           (SELECT Name, StageName, CloseDate
                                           FROM Opportunities
                                           ORDER By Name)
                                    FROM Account
                                    WHERE Id != :acc.Id];
        
        system.assertEquals(1                , clonedAccs.size());
        system.assertEquals(acc.Name         , clonedAccs[0].Name);
        system.assertEquals(acc.Phone        , clonedAccs[0].Phone);
        
        //Both of the selected contacts should have been cloned.
        system.assertEquals(2                , clonedAccs[0].Contacts.Size());
        system.assertEquals(cons[0].FirstName, clonedAccs[0].Contacts[0].FirstName);
        system.assertEquals(cons[0].LastName , clonedAccs[0].Contacts[0].LastName);
        system.assertEquals(cons[0].email    , clonedAccs[0].Contacts[0].Email);
        system.assertEquals(cons[1].FirstName, clonedAccs[0].Contacts[1].FirstName);
        system.assertEquals(cons[1].LastName , clonedAccs[0].Contacts[1].LastName);
        system.assertEquals(cons[1].email    , clonedAccs[0].Contacts[1].Email);
        
        // Only 1 opportunity should have been cloned.
        system.assertEquals(1                , clonedAccs[0].Opportunities.Size());
        system.assertEquals(opps[1].Name     , clonedAccs[0].Opportunities[0].Name);
        system.assertEquals(opps[1].StageName, clonedAccs[0].Opportunities[0].StageName);
        system.assertEquals(opps[1].CloseDate, clonedAccs[0].Opportunities[0].CloseDate);
        
        // Finally, verify that the page reference returned by the clone method is the 
        // view page of the new account object.
        system.assertEquals('/' + clonedAccs[0].Id, doCloneResult.getURL());
    }

    /*
        Test the "doClone" method. This method clones the parent object held in the controller,
        and all the related objects that have also been selected for copying.

        Initialise an instance of the clone plus controller, and don't populate the class variables with
        some sample objects. This will cause an error when calling the do clone method, 
        as there is nothing to clone. 
  
        Instead of redirecting the user, a page message should be added to the current page, 
        informing the user of the error that has occured.
    */
    private static testmethod void testDoCloneFailure()
    {   
        // Initialise a test instance of a clone plus controller.
        ClonePlusController cpc = new ClonePlusController();
        
        // Call the do clone method. Record the page reference passed in response. 
        PageReference doCloneResult = cpc.doClone();
        
        // Verify that that a null page reference is returned by the method call, 
        // and that a page message has been generated to inform the user a fault has occured.
        system.assertEquals(1, ApexPages.getMessages().size());
    }
    
    /*
     When the related object rows have been populated, the getId method can be used to interrogate
     the generic sobject and retrieve the id field of the object.
      
     However, if no id field is visible to the user, then this should not cause an exception to occur.
     Instead, a blank value should be returned.
    */
    private static testmethod void testGetObjectIdException()
    {
     // Start the test by intialising some object rows in the controller. 
     // One object row will not contain an object, resulting in a (caught) exception 
     // when the id is requested.
     Contact testSavedContact = new Contact(FirstName = 'Sam', LastName = 'Ple', 
                                            email='test1@salesforce.com');
     insert testSavedContact;
     
     ClonePlusController.RelatedObjectRow testSavedContactRow 
                         = new ClonePlusController.RelatedObjectRow(testSavedContact);
     ClonePlusController.RelatedObjectRow testNullObjectRow   
                         = new ClonePlusController.RelatedObjectRow(null);
     
     System.assertEquals(testSavedContact.Id, testSavedContactRow.getId());
     System.assertEquals(''                 , testNullObjectRow.getId());
    }
}


My advice to all those using the test class (especially who have previously asked about testing), is that you should use this as a base for building your own test methods. Substitute the test Account -> Contact and Account -> Opportunities relationships used in the test for the objects you are going to clone in your own org.

One last thought, although the minimum coverage is 75%, I have built the test class so that the coverage reaches 100%. As a general rule of thumb I always intend to write complete coverage of code, as the more coverage you have, the more likely you are to catch the introduction of a bug in your system after some new development, or a new release.

Thursday, 27 June 2013

Book Review: Salesforce CRM Admin Cookbook


Recently, I have been reading the "Salesforce CRM Admin Cookbook" by Paul Goodey. The book contains a selection of guides (or recipes) for solving common problems and adding additional functionality to your Salesforce org. Here are my thoughts....

Salesforce CRM Admin Cookbook


The book itself provides simple and effective remedies to many common problems I have experienced during my time as a Salesforce developer/administrator. The solutions themselves are presented in an impressively straightforward manner. There is no unnecessary waffle or fluff, just straight to the point step by step instructions on how to practically and quickly solve common bug bears and improve your org for your users.

The highlight of the book for me was a recipe that dealt with extending the standard input date calendar range. This is something that has always bugged me, I can't speak for the majority of company's out there, but most of my contacts were born before 2012 :) . The proposed solution is simple and maintains consistency across the whole org. Other personal highlights include validation rules for verifying UK and US postcodes, a step by step guide to email integration and creating buttons on related lists to carry out actions on multiple records simultaneously.

In my view the best way to get the most out of the book is to read through and try out a new recipe each day, rather than trying to find a solution for a situation when it arises. The individual solutions do not take a lot of time to implement, and will give you a definite appreciation of the tools available to you as a SF admin.

A slight drawback of some of the recipes is a dependency on the current Salesforce styling and specific component id names. However, Paul Goodey, the author, makes you fully aware of this, and the nature of the solutions means that if the standard Salesforce page components were ever to change, administrators could update the content to match the new HTML standard. What is important about the solutions described is not necessarily the source code or specific problem, but rather the tools, approaches and techniques used, which can be adapted to a wide range of other scenarios.

In summary, if you are a Salesforce administrator looking to explore some of the advanced features of the platform, or want to know how to alter some of the standard appearance and functionality of your org to suit your users needs, then the Salesforce CRM Admin Cookbook is a must have. I challenge any admin to read this book without having a "Oh, that's how you can do that!" moment or six.

The book can be purchased online at Amazon

Friday, 26 April 2013

Bristol DUG - FxFW - Mobile Week Meetup

Force by Force West (FxFW) is a Force.com developer user group for Bristol and the south west. Last night, at the Llandoger trow, the group met as part of the Salesforce Mobile developer week.

Peter Chittum, Developer Evangelist/Trainer from Salesforce.com, was on hand to present the new mobile functionality and libraries. The demonstration contained lots of actual code examples (much to the delight of the audience) as well as exploring some of the design choices involved in creating mobile apps and integrating with the Force.com platform.




But it didn't just stop there, discussions branched into security (particularly the value of OAuth best practices and suggestions), certifications and training (who knew there was a 502 Force.com integration class??), and the best ways to introduce developers from other platforms to the world of cloud development.




As part of the evening, we announced that the FxFW group is holding a mobile hack challenge for developers keen to test their skills and learn about the new mobile offering, with the top prize being a new shiny iPod nano (ooooooh!). For more information on the group and upcoming events and hack challenges, visit our Meetup group page. hope to see you at our next event!

Tuesday, 18 December 2012

Clone Plus: A quick update

After posting the original clone plus implementation, it has been pointed out that it doesn't work for objects that do not have "Name" fields (I didn't even realise such objects existed!). However, as far as I am aware, every object has an Id field, so here is an altered implementation that will work for the objects missing the name field.

Controller:
public class ClonePlusController {

  public List<relatedObjects> objectChildren  { get; set; }
  public String               objectTypeName  { get; set; }
  public String               objectName      { get; set; }
   
  private SObject headSObject, headClone;
  
  // Initialisation method called when the clone plus page is  loaded.
  // Use the id page parameter to find out what object type to clone.
  // Then load the object from the database.
  // Finally call the populateObjectChildren method to      
  public void initialiseObjectsForCloning()
  {

    // Here we generate a keyprefixmap using the global describe 
    // Then compare that to our object to determine type.  
    Map<String, Schema.SObjectType> gd = Schema.getGlobalDescribe(); 
       
    Map<String,String> keyPrefixMap = new Map<String,String>{};
          
    for(String sObj : gd.keySet()){
      Schema.DescribeSObjectResult r =  gd.get(sObj).getDescribe();
      keyPrefixMap.put(r.getKeyPrefix(), r.getName());
    }
      
    String objectID = ApexPages.currentPage().getParameters().get('id');
    String objectTypeKey = objectId.subString(0,3);
      
    objectTypeName = keyPrefixMap.get(objectTypeKey);
      
    String primaryObjectQueryString = 'SELECT Id, Name FROM '
                                    + objectTypeName
                                    + ' WHERE Id = \''
                                    + objectId
                                    + '\'';
    
    headSObject = Database.query(primaryObjectQueryString);
    objectName          = '' + headSObject.get('Name');
    populateObjectChildren();    
  }

  // Get all of the children of the current object that have a object
  // type contained in the child object types page parameter.
  // Not restricting the child objects to particular types results in
  // many uncloneable system objects being added to the possibiilites.
  // Making these object type choices also allows us 
  // pick and chose the specific kinds of objects we want to clone.  
  public void populateObjectChildren()
  {
       
    objectChildren = new List<relatedObjects>{};
        
    Set<String> childObjectTypes = new Set<String>{};
    
    // read the object types from the page parameter.    
    childObjectTypes.addAll(
         ApexPages.currentPage().getParameters()
                  .get('childobjecttypes').split(',')
    );
    
    // Use the sobjecttype describe method to retrieve all 
    // child relationships for the object to be cloned.    
    Schema.DescribeSObjectResult headDescribe = 
           headsObject.getSObjectType().getDescribe();
    
    List<Schema.ChildRelationship> childRelationships = 
           headDescribe.getChildRelationships(); 
    
    // Iterate through each relationship, retrieve related objects.       
    for (Schema.ChildRelationship childRelationship : 
                                   childRelationships)
    {
      Schema.SObjectType childObjectType = 
             childRelationship.getChildSObject();
      
      // Only retrieve the objects if their type is 
      // one of those we are interested in.          
      if (childObjectTypes.contains(
          childObjectType.getDescribe().getName()))
      {
        List<relatedObjectRow> relatedObjects = 
                    new List<relatedObjectRow>{};
                
        Schema.SObjectField childObjectField = 
                            childRelationship.getField();
                
        String relatedChildSObjectsquery = 'SELECT ID FROM ' 
                        + childObjectType.getDescribe().getName()
                        + ' WHERE '
                        + childObjectField.getDescribe().getName()
                        + ' = \'' 
                        + headsObject.Id
                        + '\''; 
                                                        
        for (SObject childObject : 
                     Database.query(relatedChildSObjectsquery))
        {
          relatedObjects.add(new relatedObjectRow(childObject));
        }
            
        if (!relatedObjects.isEmpty())
        {
          objectChildren.add(new relatedObjects(relatedObjects, 
                 childObjectType.getDescribe().getLabelPlural(), 
                 childObjectField.getDescribe().getName()));
        }  
      }
    }
  }
  
  // Perform the cloning process.
  // First clone the parent, then all of the child objects. 
  // Then redirect the user to the new object page.
  public PageReference doClone()
  {
    headClone = cloneObjects(new List<sObject>{headSObject}).get(0);
    
    insert headClone;
    
    cloneSelectedObjects();
    
    return new PageReference('/' + headClone.Id);
  }
  
  // Clone the selected child objects.
  // Associate the cloned objects with the new cloned parent object.
  public void cloneSelectedObjects()
  {
        
    List<sObject> clonedObjects = new List<sObject>{};
    List<sObject> selectedRelatedObjects;
     
    for (relatedObjects relatedObject : objectChildren)
    {
      selectedRelatedObjects = new List<sObject>{};  
      clonedObjects = new List<sObject>{};  
      
      for (relatedObjectRow row : relatedObject.objectRows) 
      {
        if (row.selected)
        {
          selectedRelatedObjects.add(row.obj);
        }
      }
      
      if (!selectedRelatedObjects.isEmpty())
      {
        clonedObjects = cloneObjects(selectedRelatedObjects);
        
        for (sObject clone : clonedObjects)
        {
          clone.put(relatedObject.relatedFieldName, headClone.Id);  
        }
        
        insert clonedObjects;
      }
    }
  }

  // Clone a list of objects to a particular object type
  // Parameters 
  // - List<sObject> sObjects - the list of objects to be cloned 
  // The sObjects you pass in must include the ID field, 
  // and the object must exist already in the database, 
  // otherwise the method will not work.
  public static List<sObject> cloneObjects(List<sObject> sObjects){
                                                
    Schema.SObjectType objectType = sObjects.get(0).getSObjectType();
    
    // A list of IDs representing the objects to clone
    List<Id> sObjectIds = new List<Id>{};
    // A list of fields for the sObject being cloned
    List<String> sObjectFields = new List<String>{};
    // A list of new cloned sObjects
    List<sObject> clonedSObjects = new List<sObject>{};
    
    // Get all the fields from the selected object type using 
    // the get describe method on the object type.    
    if(objectType != null)
    {
      for (Schema.SObjectField objField : 
           objectType.getDescribe().fields.getMap().values())
      { 
        Schema.DescribeFieldResult fieldDesc = objField.getDescribe();
        // If the field type is location, then do not include it,
        // otherwise it will cause a soql exception.
        // Note that excluding the field does not stop the location from
        // being copied to the new cloned object.
        if(fieldDesc.getType() != DisplayType.LOCATION)
        {
          sObjectFields.add(fieldDesc.Name);
        }
      }
    }
    
    // If there are no objects sent into the method, 
    // then return an empty list
    if (sObjects != null || 
        sObjects.isEmpty() || 
        sObjectFields.isEmpty()){
    
      // Strip down the objects to just a list of Ids.
      for (sObject objectInstance: sObjects){
        sObjectIds.add(objectInstance.Id);
      }

      /* Using the list of sObject IDs and the object type, 
         we can construct a string based SOQL query 
         to retrieve the field values of all the objects.*/
    
      String allSObjectFieldsQuery = 'SELECT ' + 
                                     String.join(sObjectFields,','); 
    
      allSObjectFieldsQuery += ' FROM ' + 
                               objectType.getDescribe().getName() + 
                               ' WHERE ID IN (\'' + sObjectIds.get(0) + 
                               '\'';

      for (Integer i=1 ; i < sObjectIds.size() ; i++){
        allSObjectFieldsQuery += ', \'' + sObjectIds.get(i) + '\'';
      }
    
      allSObjectFieldsQuery += ')';
    
      system.debug('allSObjectFieldsQuery: ' + allSObjectFieldsQuery);
    
      try{
      
        // Execute the query. For every result returned, 
        // use the clone method on the generic sObject 
        // and add to the collection of cloned objects
        for (SObject sObjectFromDatabase:
             Database.query(allSObjectFieldsQuery)){
          clonedSObjects.add(sObjectFromDatabase.clone(false,true));  
        }
    
      } catch (exception e){
      }
      
    }
   
    return clonedSObjects;
    
  }
  
  // Related objects data construct - 
  // used to store a collection of child objects
  // connected to the head object through 
  // the same relationship field.
  public class relatedObjects
  {
    public List<relatedObjectRow> objectRows       { get; set; }
    public String                 pluralLabel      { get; set; }
    public String                 relatedFieldName { get; set; }
    
    public relatedObjects(List<relatedObjectRow> objectRows, 
                          String pluralLabel, 
                          String relatedFieldName) 
    {
      this.objectRows       = objectRows;
      this.pluralLabel      = pluralLabel;
      this.relatedFieldName = relatedFieldName;
    }   
  }     

  // An indidual child object row. 
  // It simple contains the object definition, 
  // and a checkbox to select the row for cloning 
  // on the clone plus page.
  public class relatedObjectRow
  {
    public sObject obj      { get; set; }
    public Boolean selected { get; set; }
    
    public relatedObjectRow(Sobject obj)
    {
      this.obj      = obj;
      // All object rows are selected by default.
      this.selected     = true;
    }
    
    public String getId(){
      try{
        return '' + obj.get('Id');
      } catch (Exception e){
        return '';
      }    
    }
  }
}

Page:
<apex:page controller="ClonePlusController" 
                   action="{!initialiseObjectsForCloning}">
  
  <apex:sectionHeader title="Clone Plus: {!objectName}"/>

  <apex:form id="theform" >
  
    Please select the child objects you would like to clone.<br/><br/>
  
    <apex:repeat value="{!objectChildren}" var="child">
      <apex:PageBlock title="{!child.pluralLabel}"> 
        <apex:pageBlockTable value="{!child.objectRows}" 
                                var="objectRow">
          <apex:column headerValue="Clone" width="10%">
            <apex:inputCheckbox value="{!objectRow.selected}"/>
          </apex:column>
       <apex:column headerValue="Id"   
                              value="{!objectRow.Id}" 
                              width="45%"/>
        </apex:pageBlockTable>
      </apex:PageBlock>
    </apex:repeat>
    
    <apex:PageBlock >
      <apex:commandButton action="{!doClone}" value="Clone"/>
    </apex:PageBlock>
  </apex:form>  

</apex:page>


At this current point in time, my plan is to develop the next release of clone plus on the AppExchange as a free app, with a much better management facility, so you can control what you are cloning much more effectively. I'll keep you posted!

Monday, 19 November 2012

Using Ids and strings in SOQL queries, a word of warning.

When querying your sObject data to retrieve particular objects, a common approach is to use Id or String fields as the criteria in a SOQL query.

Take a look at the following sample code that retrieves account objects by Id, and updates related contacts so that they are opted in to future fax and email communications. This type of function may be commonly used in batch operations or triggers.

// Opt all contacts related to any of a list of accounts
// in to email and fax communications. 
public static void optInContacts (List<Account> updatedAccounts)
{
    // Put the accounts Ids in a set
    Set<Id> accountIds = new Set<Id>{};
        
    for (Account acc: updatedAccounts)
    {
        accountIds.add(acc.Id);
    }
        
    // Use SOQL to retrieve the related contacts
    List<Contact> relatedContacts = 
        [SELECT HasOptedOutOfFax, HasOptedOutOfEmail
         FROM Contact
         WHERE AccountId IN :accountIds];
        
    // For each contact set opt out preferences
    for (Contact con : relatedContacts)
    {
        con.HasOptedOutOfFax   = false;
        con.HasOptedOutOfEmail = false;
    }
        
    // Update the contact records
    update relatedContacts;
}

Doesn't look too unusual right? However, upon running this method, you may affect more records than you intend to. The problem is that internal, non-API Salesforce code uses 15 character unique object Ids. These object Ids are case sensitive (externally 18 character insensitive Ids including check digits are available). However, database query tool SOQL is not case sensitive.

So in the above example imagine there were two account objects in your org with Ids 01rG0000000FBr6 and 01rG0000000FbR6 respectively. If you ran the code operation on a collection that contains the account with Id 01rG0000000FBr6, then both records would be picked up by the case-insensitive query and processed, potentially signing up contacts for communications when they have already refused.

There are a few ways to deal with this. As a general rule I would recommend using names as oppose to Ids. This however comes with the caveat that all your names are alphabetically unique. This will definitely work if the name is an auto-number field.

Alternatively you could use the set.contains method to remove the incorrect values before you process the results. The updated method looks something like this:

public class IdInsensitivityTest{

    // Opt all contacts related to any of a list of accounts
    // in to email and fax communications. 
    public static void optInContacts (List<Account> updatedAccounts)
    {
        // Put the accounts Ids in a set
        Set<Id> accountIds = new Set<Id>{};
        
        for (Account acc: updatedAccounts)
        {
            accountIds.add(acc.Id);
        }
        
        // Use SOQL to retrieve the related contacts
        List<Contact> relatedContacts = new List<Contact>{};
        
        for (Contact c : [SELECT AccountId, HasOptedOutOfFax, HasOptedOutOfEmail
                          FROM Contact
                          WHERE AccountId IN :accountIds])
        {
            // Use the set "contains" method to determine if 
            // the contacts related account is correct
            // Even though SOQL is not case sensitive, Apex is
            if (accountIDs.contains(c.AccountId))
            {
                relatedContacts.add(c);
            }
        }
        
        // For each contact set opt out preferences
        for (Contact con : relatedContacts)
        {
            con.HasOptedOutOfFax   = false;
            con.HasOptedOutOfEmail = false;
        }
        
        // Update the contact records
        update relatedContacts;
    }
}

You may think that this is overkill, and that the chances 15 length Ids will match alphabetically are pretty low, but if your functionality does something drastic like delete data, should you really be taking that chance? I urge you to consider this, as I will, in any further development involving id or string criteria in SOQL queries.

PS -> Thanks to Desynit's very own Gary McGeorge, who pointed out that old Sherlock Holmes saying :"when you have eliminated the impossible, whatever remains, however improbable, must be the truth?", Which certainly helped me work out why this was happening. Always something to bear in mind when tackling problems of this nature.

Monday, 8 October 2012

Making all approval comments mandatory in a multi-step approval process.

A month ago, I produced a blog post that outlined how a picklist field and apex trigger could be used to make rejection comments in approval processes mandatory.

More recently, a question appeared on the force.com stack overflow relating to this post. The user was wondering if it was possible to make comments mandatory for all steps in an approval process that includes multiple approval steps.

Referring back to the previous post, I made comments mandatory on rejections by adding a field update action on final rejection. This field update would be trapped inside a before update trigger, which would go on to check the latest step object in the approval process for a comment. If no comment existed, it would produce an error asking the user to enter a reason for the rejection.

This works well for single step approvals or rejections, but multiple steps are more complex. Approval and rejection actions can be associated with individual steps inside an approval process, like so:



However, it is not quite that simple. The Salesforce order of execution complicates matters. The final approval / rejections work because the actions, such as field update, occur after the latest approval step has been stored, so we can use a trigger to look to check the most recent entry for comments.

This is not the case for individual approval step actions. The field updates occur before the latest approval step object is stored. This means that when the trigger tries to check for the comment, it can't retrieve the latest step object. This made me think that it may not be possible to accomplish. Then I found the re-trigger workflow option on the field update edit page:



If the update could cause workflow to fire again, then I realized it would be possible by adding a workflow rule that caused another update to the same object. This would avoid the order of execution problems, because the workflow and resulting update would occur after the insertion of the step, not before like the previous update.

So it is possible to create mandatory comments for each step, using a double step trigger. Here are the steps to make this possible:

1) Create a new picklist field on your approval process object called "Approval Comment Check". Assign two picklist values "Required" and "Requested", with no default, like this:


2) Create a new workflow field update action, call it "Approval Comment Required" and configure it so that your new Approval Comment Check field is updated to the "Required" value. Check the "Re-evaluate Workflow Rules after Field Change" checkbox.



 3) Create another field update action, called "Approval Comment Requested". This update should change the Approval Comment Check field to the "Requested" value. This time, do not check the "Re-evaluate Workflow Rules after Field Change" option.


4) Create a new workflow rule, on your object going through the approval process, called "Approval Comment Flag". Set the evaluation criteria to "When a record is created, or when a record is edited and did not previously meet the rule criteria". The rule criteria should be defined as when the Approval Comment Check field equals "Required". When you have finished, click Save & Next


5) On the new workflow summary page, click Add Workflow Action  underneath Immediate Workflow Actions. From the drop menu, click on Select Existing Action. Add the "Approval Comment Requested" rule you created in step 3 and then click Done. Your Summary Screen should look something like this. 

!!!!Don't forget to activate your workflow rule before continuing!!!! 



6) Create the following before update trigger on your approval process object.

trigger RequireApprovalComments on Invoice_Statement__c (before update) 
{
  // Create a map that stores all the objects that require editing 
  Map<Id, Invoice_Statement__c> approvalStatements = 
  new Map<Id, Invoice_Statement__c>{};

  for(Invoice_Statement__c inv: trigger.new)
  {
    // Put all objects for update that require a comment check in a map,
    // so we only have to use 1 SOQL query to do all checks
    
    if (inv.Approval_Comment_Check__c == 'Requested')
    { 
      approvalStatements.put(inv.Id, inv);
      // Reset the field value to null, 
      // so that the check is not repeated,
      // next time the object is updated
      inv.Approval_Comment_Check__c = null; 
    }
  }  
   
  if (!approvalStatements.isEmpty())  
  {
    // UPDATE 2/1/2014: Get the most recent process instance for the approval.
    // If there are some approvals to be reviewed for approval, then
    // get the most recent process instance for each object.
    List<Id> processInstanceIds = new List<Id>{};
    
    for (Invoice_Statement__c invs : [SELECT (SELECT ID
                                              FROM ProcessInstances
                                              ORDER BY CreatedDate DESC
                                              LIMIT 1)
                                      FROM Invoice_Statement__c
                                      WHERE ID IN :approvalStatements.keySet()])
    {
        processInstanceIds.add(invs.ProcessInstances[0].Id);
    }
      
    // Now that we have the most recent process instances, we can check
    // the most recent process steps for comments.  
    for (ProcessInstance pi : [SELECT TargetObjectId,
                                   (SELECT Id, StepStatus, Comments 
                                    FROM Steps
                                    ORDER BY CreatedDate DESC
                                    LIMIT 1 )
                               FROM ProcessInstance
                               WHERE Id IN :processInstanceIds
                               ORDER BY CreatedDate DESC])
    {
      // If no comment exists, then prevent the object from saving.                 
      if ((pi.Steps[0].Comments == null || 
           pi.Steps[0].Comments.trim().length() == 0))
      {
        approvalStatements.get(pi.TargetObjectId).addError(
         'Operation Cancelled: Please provide a reason ' + 
         'for your approval / rejection!');
      }
    }                                       
  }
}

7) Finally in your approval process, at each step you want a comment to be mandatory, add the "Approval Comment Required" field update created in step 2 to the approval actions. You can also add this update to the rejection action to make a comment mandatory for rejection at a particular step, although as a tip, if you want to make a comment mandatory on all rejections, simply add the field update to the final rejection actions (note this only works if the rejection behaviour is 'Final Rejection' rather than 'Go Back 1 Step'.



I admit that this is not the prettiest of solutions, but it does accomplish what was intended, all approvals do now have to be commented. One thing to be mindful of is that this approval method uses a two step update process, so if you have any update triggers for the approval object, they will be fired twice every time an approval step is completed.

Please vote up this community idea to accomplish this through the standard menu. As much as I enjoy working out how to accomplish feats like this with force.com, I would prefer it came out of the box :D
UPDATE 2/1/2014 sample test method:
/*
    A sample test class for the Require Approval Comments trigger
    Obviously adapt it to your own approval processes.
*/
@isTest
public class RequireApprovalCommentsTest
{
    /*
        For this first test, create an object for approval, then
        simulate rejeting the approval with an added comment for explanation.
        
        The rejection should be processed normally without being interrupted.
    */
    private static testmethod void testRejectionWithComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // Reject the submitted request, providing a comment.
        Approval.ProcessWorkitemRequest testRej = new Approval.ProcessWorkitemRequest();
        testRej.setComments('Rejecting request with a comment.');
        testRej.setAction  ('Reject');
        testRej.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
            // Process the rejection
            Approval.ProcessResult testRejResult =  Approval.process(testRej);
        Test.stopTest();
        
        // Verify the rejection results
        System.assert(testRejResult.isSuccess(), 'Rejections that include comments should be permitted');
        System.assertEquals('Rejected', testRejResult.getInstanceStatus(), 
          'Rejections that include comments should be successful and instance status should be Rejected');
    }
    
    /*
        For this test, create an object for approval, then reject the request,
        without a comment explaining why. The rejection should be halted, and
        and an apex page message should be provided to the user.
    */
    private static testmethod void testRejectionWithoutComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // Reject the submitted request, without providing a comment.
        Approval.ProcessWorkitemRequest testRej = new Approval.ProcessWorkitemRequest();
        testRej.setComments('');
        testRej.setAction  ('Reject');      
        testRej.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
        // Attempt to process the rejection
        try
        {
          Approval.ProcessResult testRejResult =  Approval.process(testRej);
          system.assert(false, 'A rejection with no comment should cause an exception');
        }
        catch(DMLException e)
        {
          system.assertEquals(
             'Operation Cancelled: Please provide a reason for your approval / rejection!', 
             e.getDmlMessage(0), 
             'error message should be Operation Cancelled: Please provide a rejection reason!'); 
        }
        Test.stopTest();
    }
    
    /*
        When an approval is approved instead of rejected, a comment is also required.
        Mark an approval as approved with a comment, it should be successful.
    */
    private static testmethod void testApprovalWithComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // approve the submitted request, providing a comment.
        Approval.ProcessWorkitemRequest testApp = new Approval.ProcessWorkitemRequest();
        testApp.setComments ('Sample approval comment');
        testApp.setAction   ('Approve');
        testApp.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
            // Process the approval
            Approval.ProcessResult testAppResult =  Approval.process(testApp);
        Test.stopTest();
        
        // Verify the approval results
        System.assert(testAppResult.isSuccess(), 
                      'Approvals that include comments should still be permitted');
    }
    
    /*
        When an approval is approved instead of rejected, a comment is also required.
        Mark an approval as approved without a comment, it should be rejected and held back.
    */
    private static testmethod void testApprovalWithoutComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // approve the submitted request, without providing a comment.
        Approval.ProcessWorkitemRequest testApp = new Approval.ProcessWorkitemRequest();
        testApp.setComments ('');
        testApp.setAction   ('Approve');
        testApp.setWorkitemId(testWorkItemId);
        
        // Verify the approval results
        Test.startTest();        
       // Attempt to process the approval
        try
        {
          Approval.ProcessResult testAppResult =  Approval.process(testApp);
          system.assert(false, 'An with no comment should cause an exception');
        }
        catch(DMLException e)
        {
          system.assertEquals(
             'Operation Cancelled: Please provide a reason for your approval / rejection!', 
             e.getDmlMessage(0), 
             'error message should be Operation Cancelled: Please provide a rejection reason!'); 
        }
        Test.stopTest();        
        
    }    
    
    /*
        Put many objects through the approval process, some rejected, some approved,
        some with comments, some without. Only approvals and rejctions without comments should be
        prevented from being saved.
    */
    private static testmethod void testBatchRejctions()
    {
        List<Invoice_Statement__c> testBatchIS = new List<Invoice_Statement__c>{};
        for (Integer i = 0; i < 200; i++)
        {
            testBatchIS.add(new Invoice_Statement__c());
        }   
           
        insert testBatchIS;
        
        List<Approval.ProcessSubmitRequest> testReqs = new List<Approval.ProcessSubmitRequest>{}; 
        for(Invoice_Statement__c testinv : testBatchIS)
        {
            Approval.ProcessSubmitRequest testReq = new Approval.ProcessSubmitRequest();
            testReq.setObjectId(testinv.Id);
            testReqs.add(testReq);
        }
        
        List<Approval.ProcessResult> reqResults = Approval.process(testReqs);
        
        for (Approval.ProcessResult reqResult : reqResults)
        {
            System.assert(reqResult.isSuccess(), 
                         'Unable to submit new batch invoice statement record for approval');
        }
        
        List<Approval.ProcessWorkitemRequest> testAppRejs = new List<Approval.ProcessWorkitemRequest>{};
        
        for (Integer i = 0; i < 50 ; i++)
        {
            Approval.ProcessWorkitemRequest testRejWithComment = new Approval.ProcessWorkitemRequest();
            testRejWithComment.setComments  ('Rejecting request with a comment.');
            testRejWithComment.setAction    ('Reject');
            testRejWithComment.setWorkitemId(reqResults[i*4].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testRejWithComment);
            
            Approval.ProcessWorkitemRequest testRejWithoutComment = new Approval.ProcessWorkitemRequest();
            testRejWithoutComment.setAction    ('Reject');
            testRejWithoutComment.setWorkitemId(reqResults[(i*4)+1].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testRejWithoutComment);
            
            Approval.ProcessWorkitemRequest testAppWithComment = new Approval.ProcessWorkitemRequest();
            testAppWithComment.setComments  ('Approving request with a comment.');
            testAppWithComment.setAction    ('Approve');
            testAppWithComment.setWorkitemId(reqResults[(i*4)+2].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testAppWithComment);
            
            Approval.ProcessWorkitemRequest testAppWithoutComment = new Approval.ProcessWorkitemRequest();
            testAppWithoutComment.setAction    ('Approve');
            testAppWithoutComment.setWorkitemId(reqResults[(i*4)+3].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testAppWithoutComment);            
        }
            
        Test.startTest();        
            // Process the approvals and rejections
            try
            {
                List<Approval.ProcessResult> testAppRejResults =  Approval.process(testAppRejs);
                system.assert(false, 'Any rejections without comments should cause an exception');
            }
            catch(DMLException e)
            {
                system.assertEquals(100, e.getNumDml());
                
                for(Integer i = 0; i < 50 ; i++)
                {
                  system.assertEquals((i*4) + 1, e.getDmlIndex(i * 2));
                  system.assertEquals(
                    'Operation Cancelled: Please provide a reason for your approval / rejection!', 
                    e.getDmlMessage((i * 2)));
                  system.assertEquals((i*4) + 3, e.getDmlIndex((i * 2) + 1 ));
                  system.assertEquals(
                    'Operation Cancelled: Please provide a reason for your approval / rejection!', 
                    e.getDmlMessage((i * 2) + 1 ));
                }
            }    
        Test.stopTest();
    }
    
    /*
        Utility method for creating single object, and submitting for approval.
        
        The method should return the Id of the work item generated as a result of the submission.
    */
    private static Id generateAndSubmitObject()
    {
        // Create a sample invoice statement object and then submit it for approval.
        Invoice_Statement__c testIS = new Invoice_Statement__c();
        insert testIS;
        
        Approval.ProcessSubmitRequest testReq = new Approval.ProcessSubmitRequest();
        testReq.setObjectId(testIS.Id);
        Approval.ProcessResult reqResult = Approval.process(testReq);
        
        System.assert(reqResult.isSuccess(),'Unable to submit new invoice statement record for approval');
        
        return reqResult.getNewWorkitemIds()[0];
    }
}

Saturday, 8 September 2012

Making rejection comments mandatory in approval processes

When I was recently asked to set up an approval process on a custom object in a application, I noticed that it was possible to reject an approval request without having to populate the comment field.

This didn't seem to make much sense to me, if an approval is rejected, it is likely that the requesting user will want to know why this is the case. I was surprised to find that there was no setup option to configure the process this way. 

I found that it is possible to make comments mandatory through adding a custom field, some extra steps to your approval process, and an apex trigger for the object. Here is a step by step guide if you want to do the same:

1) Create a new picklist custom field for your object. Set the label as "Approval Status". Add three picklist values ("Pending", "Approved" & "Rejected"). Make this field visible and editable by System Administrators only.




2) Navigate to the field updates menu (Setup --> App Setup --> Create --> Workflows & Approvals --> Field Updates) and create a new field update worfkflow action. Set the name of the new action to "Approval Status Pending" and unique name to "Approval_Status_Pending" and object to the object you added the custom field to. Then select the Approval Status field from the resulting drop down. In the new field value options, select "A specific value" then pick "Pending" status.



3) Repeat step 2 for the "Approved" and "Rejected" picklist values, creating field updates called "Approval Status Approved" and "Approval Status Rejected" respectively.



4) Navigate to your approval process. In "Initial Submission Actions" add the "Approval Status Pending" field update action. Add "Approval Status Approval" update to "Final Approval Actions" and "Approval Status Rejection" to "Final Rejection Actions".



5) Create a new "RequireRejectionComment" before update Apex trigger on your object with the following body (substituting "Invoice_Statement__c" for your object API name).

trigger RequireRejectionComment on Invoice_Statement__c (before update) 
{

  Map<Id, Invoice_Statement__c> rejectedStatements 
             = new Map<Id, Invoice_Statement__c>{};

  for(Invoice_Statement__c inv: trigger.new)
  {
    /* 
      Get the old object record, and check if the approval status 
      field has been updated to rejected. If so, put it in a map 
      so we only have to use 1 SOQL query to do all checks.
    */
    Invoice_Statement__c oldInv = System.Trigger.oldMap.get(inv.Id);

    if (oldInv.Approval_Status__c != 'Rejected' 
     && inv.Approval_Status__c == 'Rejected')
    { 
      rejectedStatements.put(inv.Id, inv);  
    }
  }
   
  if (!rejectedStatements.isEmpty())  
  {
    // UPDATE 2/1/2014: Get the most recent approval process instance for the object.
    // If there are some approvals to be reviewed for approval, then
    // get the most recent process instance for each object.
    List<Id> processInstanceIds = new List<Id>{};
    
    for (Invoice_Statement__c invs : [SELECT (SELECT ID
                                              FROM ProcessInstances
                                              ORDER BY CreatedDate DESC
                                              LIMIT 1)
                                      FROM Invoice_Statement__c
                                      WHERE ID IN :rejectedStatements.keySet()])
    {
        processInstanceIds.add(invs.ProcessInstances[0].Id);
    }
      
    // Now that we have the most recent process instances, we can check
    // the most recent process steps for comments.  
    for (ProcessInstance pi : [SELECT TargetObjectId,
                                   (SELECT Id, StepStatus, Comments 
                                    FROM Steps
                                    ORDER BY CreatedDate DESC
                                    LIMIT 1 )
                               FROM ProcessInstance
                               WHERE Id IN :processInstanceIds
                               ORDER BY CreatedDate DESC])   
    {                   
      if ((pi.Steps[0].Comments == null || 
           pi.Steps[0].Comments.trim().length() == 0))
      {
        rejectedStatements.get(pi.TargetObjectId).addError(
          'Operation Cancelled: Please provide a rejection reason!');
      }
    }  
  }
}

The trigger captures any update where the approval status is changed to "rejected" from another value. The field should have this value if it gets rejected, thanks to the way we set up the approval process. 

If the field has been updated in this way, a SOQL query is used to analyse the last created rejection approval history object related to the trigger object. If the comment is empty, the update gets stopped, and an error message is added to the screen.

Here are some screen shots of rejecting a request in chatter, and the resulting error message:



So there you have it, now all your rejections must have a comment, otherwise the operation is reversed! Because the rejection logic is handled in a trigger, this approach works for rejections through the standard pages, in chatter, and in Apex code.

Update 30/12/2013 : here is a sample test method for the trigger, hope it helps, remember to adapt it for your own implementation
/*
    A sample test class for the Require Rejection Comment trigger
*/
@isTest
public class RequireRejectionCommentTest
{
    /*
        For this first test, create an object for approval, then
        simulate rejeting the approval with an added comment for explanation.
        
        The rejection should be processed normally without being interrupted.
    */
    private static testmethod void testRejectionWithComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // Reject the submitted request, providing a comment.
        Approval.ProcessWorkitemRequest testRej = new Approval.ProcessWorkitemRequest();
        testRej.setComments('Rejecting request with a comment.');
        testRej.setAction  ('Reject');
        testRej.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
            // Process the rejection
            Approval.ProcessResult testRejResult =  Approval.process(testRej);
        Test.stopTest();
        
        // Verify the rejection results
        System.assert(testRejResult.isSuccess(), 'Rejections that include comments should be permitted');
        System.assertEquals('Rejected', testRejResult.getInstanceStatus(), 
          'Rejections that include comments should be successful and instance status should be Rejected');
    }
    
    /*
        For this test, create an object for approval, then reject the request, mark the approval status as pending, then
        without a comment explaining why. The rejection should be halted, and
        and an apex page message should be provided to the user.
    */
    private static testmethod void testRejectionWithoutComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // Reject the submitted request, without providing a comment.
        Approval.ProcessWorkitemRequest testRej = new Approval.ProcessWorkitemRequest();
        testRej.setComments('');
        testRej.setAction  ('Reject');      
        testRej.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
            // Attempt to process the rejection
            try
            {
                Approval.ProcessResult testRejResult =  Approval.process(testRej);
                system.assert(false, 'A rejection with no comment should cause an exception');
            }
            catch(DMLException e)
            {
                system.assertEquals('Operation Cancelled: Please provide a rejection reason!', 
                                    e.getDmlMessage(0), 
                  'error message should be Operation Cancelled: Please provide a rejection reason!'); 
            }
        Test.stopTest();
    }
    
    /*
        When an approval is approved instead of rejected, a comment is not required, 
        mark the approval status as pending, then ensure that this functionality still holds together.
    */
    private static testmethod void testApprovalWithoutComment()
    {
        // Generate sample work item using utility method.
        Id testWorkItemId = generateAndSubmitObject();
        
        // approve the submitted request, without providing a comment.
        Approval.ProcessWorkitemRequest testApp = new Approval.ProcessWorkitemRequest();
        testApp.setComments ('');
        testApp.setAction   ('Approve');
        testApp.setWorkitemId(testWorkItemId);
    
        Test.startTest();        
            // Process the approval
            Approval.ProcessResult testAppResult =  Approval.process(testApp);
        Test.stopTest();
        
        // Verify the approval results
        System.assert(testAppResult.isSuccess(), 
                     'Approvals that do not include comments should still be permitted');
        System.assertEquals('Approved', testAppResult.getInstanceStatus(), 
           'All approvals should be successful and result in an instance status of Approved');
    }
    
    /*
        Put many objects through the approval process, some rejected, some approved,
        some with comments, some without. Only rejctions without comments should be
        prevented from being saved.
    */
    private static testmethod void testBatchRejctions()
    {
        List<Invoice_Statement__c> testBatchIS = new List<Invoice_Statement__c>{};
        for (Integer i = 0; i < 200; i++)
        {
            testBatchIS.add(new Invoice_Statement__c());
        }   
           
        insert testBatchIS;
        
        List<Approval.ProcessSubmitRequest> testReqs = 
                         new List<Approval.ProcessSubmitRequest>{}; 
        for(Invoice_Statement__c testinv : testBatchIS)
        {
            Approval.ProcessSubmitRequest testReq = new Approval.ProcessSubmitRequest();
            testReq.setObjectId(testinv.Id);
            testReqs.add(testReq);
        }
        
        List<Approval.ProcessResult> reqResults = Approval.process(testReqs);
        
        for (Approval.ProcessResult reqResult : reqResults)
        {
            System.assert(reqResult.isSuccess(), 
                          'Unable to submit new batch invoice statement record for approval');
        }
        
        List<Approval.ProcessWorkitemRequest> testAppRejs 
                                                  = new List<Approval.ProcessWorkitemRequest>{};
        
        for (Integer i = 0; i < 50 ; i++)
        {
            Approval.ProcessWorkitemRequest testRejWithComment = new Approval.ProcessWorkitemRequest();
            testRejWithComment.setComments  ('Rejecting request with a comment.');
            testRejWithComment.setAction    ('Reject');
            testRejWithComment.setWorkitemId(reqResults[i*4].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testRejWithComment);
            
            Approval.ProcessWorkitemRequest testRejWithoutComment = new Approval.ProcessWorkitemRequest();
            testRejWithoutComment.setAction    ('Reject');
            testRejWithoutComment.setWorkitemId(reqResults[(i*4)+1].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testRejWithoutComment);
            
            Approval.ProcessWorkitemRequest testAppWithComment = new Approval.ProcessWorkitemRequest();
            testAppWithComment.setComments  ('Approving request with a comment.');
            testAppWithComment.setAction    ('Approve');
            testAppWithComment.setWorkitemId(reqResults[(i*4)+2].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testAppWithComment);
            
            Approval.ProcessWorkitemRequest testAppWithoutComment = new Approval.ProcessWorkitemRequest();
            testAppWithoutComment.setAction    ('Approve');
            testAppWithoutComment.setWorkitemId(reqResults[(i*4)+3].getNewWorkitemIds()[0]);
            
            testAppRejs.add(testAppWithoutComment);            
        }
            
        Test.startTest();        
            // Process the approvals and rejections
            try
            {
                List<Approval.ProcessResult> testAppRejResults =  Approval.process(testAppRejs);
                system.assert(false, 'Any rejections without comments should cause an exception');
            }
            catch(DMLException e)
            {
                system.assertEquals(50, e.getNumDml());
                
                for(Integer i = 0; i < 50 ; i++)
                {
                    system.assertEquals((i*4) + 1, e.getDmlIndex(i));
                    system.assertEquals('Operation Cancelled: Please provide a rejection reason!', 
                                        e.getDmlMessage(i));
                }
            }    
        Test.stopTest();
    }
    
    /*
        Utility method for creating single object, and submitting for approval.
        
        The method should return the Id of the work item generated as a result of the submission.
    */
    private static Id generateAndSubmitObject()
    {
        // Create a sample invoice statement object and then submit it for approval.
        Invoice_Statement__c testIS = new Invoice_Statement__c();
        insert testIS;
        
        Approval.ProcessSubmitRequest testReq = new Approval.ProcessSubmitRequest();
        testReq.setObjectId(testIS.Id);
        Approval.ProcessResult reqResult = Approval.process(testReq);
        
        System.assert(reqResult.isSuccess(),'Unable to submit new invoice statement record for approval');
        
        return reqResult.getNewWorkitemIds()[0];
    }
}