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];
    }
}