Visualforce Sample - Consolidated Case History Timeline

The challenge presented for the Mastering Visualforce - Move Beyond S-Controls webinar was to recreate the functionality of an existing scontrol with Visualforce in order to highlight the advantages of this latest tool for force.com developers. Briefly, the goal of the use case behind this example is to present a consolidated list of related records under a case in a single chronologically ordered timeline.


Visualforce caseHistoryTimeline screenshot.png

Be sure to checkout the recorded webinar where this and additional valuable concepts were presented.

About this Example

The framework that will be used to describe this example mirrors the design pattern after which Visualforce has been modeled, MVC or Model-View-Controller.

This example highlights a number of Visualforce capabilities including:

  • Using an Apex class as a model object to represent heterogeneous sobject information
  • Using a custom controller in Apex to:
    • acquire related records from multiple child tables
    • handle custom actions that are invoked from a Visualforce page
    • leverage standard navigation from a custom action
    • access request parameters
  • Using property syntax instead of traditional accessor methods, e.g. getProperty(), setProperty(Object p)
  • Providing multiple views on the same model/controller:
    • Standard Salesforce UI Idioms such as a section, header and list
    • PDF Conversion with advanced formatting
      • Landscape orientation
      • Page Numbers
      • Pagebreak preference

Model

The model is the schema or data interface to the application. In this example the Model refers to both SObjects (salesforce objects) and an Apex object (Class). In terms of the interface between the view and controller the model is comprised of two basic elements:

  • Case (Standard SObject)
  • History (Apex Class)

In addition the controller refers to these additional Standard SObjects:

  • CaseComment
  • Task
  • Event
  • Attachment
  • CaseHistory

History

/* 
 * This class is used by the CaseHistoryCon controller class and the CaseHistory
 * and CaseHistoryPrint Visualforce pages. The purpose is to hold heterogenous
 * sobject information that allows multiple types to be presented in a uniform manner.
 */
public class history {

    /* Properties of the class */
    public datetime historydt { get; private set; }
    public boolean ispublic   { get; private set; }
    public string actorname   { get; private set; }
    public string historyType { get; private set; }
    public string to          { get; private set; }
    public string fr          { get; private set; }
    
    /* Class constructor */
    public History(Datetime d, boolean p, String actor, String ht, String f, String t) {
        historydt   = d;
        historydate = d.format();
        ispublic    = p;
        actorname   = actor;
        historyType = ht;
        fr          = f;
        to          = t;
    }
    
    /* Formatting methods utilized primarily by the CaseHistoryPrint Visualforce page*/
    public string historydate { get; set; }
    public string dtmonthyr   { get { return historydt.format('MMMMM yyyy'); } }
    public string dttime      { get { return historydt.format('h:mm a');} }
    public string dtdayfmt    { get { return historydt.format('d - EEEE'); } }
    public integer dtmonth    { get { return historydt.month();} }
    public integer dtyear     { get { return historydt.year();} }
    public integer dtday      { get { return historydt.day();} }
    
}

View

The view is the presentation layer and defines how your application will appear to the user. In this example the view is comprised of two Visualforce pages CaseHistory and CaseHistoryPrint. The markup for each follows.

CaseHistory

<apex:page controller="CaseHistoryCon" tabStyle="Case" sidebar="false">
    <apex:form >
        <apex:sectionHeader title="Case History" subtitle="Case Number: {!case.casenumber}"/>
        <apex:pageBlock id="thePageBlock">
            <apex:pageBlockButtons location="top">
                <apex:commandButton value="Back to Case : {!case.casenumber}" action="{!backToCase}"/>
                <apex:commandButton value="{!IF(hidePrivate, 'Show ', 'Hide ')}Private" action="{!togglePrivate}" rerender="thePageBlock" status="status"/>
                <apex:commandButton value="{!IF(fullComments, 'Short ', 'Full ')}Comments" action="{!toggleComments}" rerender="thePageBlock" status="status"/> 
                <apex:actionStatus id="status" startText="requesting..."/>
            </apex:pageBlockButtons>
            <apex:pageBlockTable value="{!histories}" var="h">
                <apex:column headerValue="Date"  value="{!h.historyDate}"/>
                <apex:column headerValue="Public">
                    <apex:image value="{!URLFOR($Resource.images_checkbox, IF(h.ispublic, 'true.gif','false.gif'))}"/>
                </apex:column>
                <apex:column headerValue="Who"  value="{!h.actorname}"/>
                <apex:column headerValue="What" value="{!h.historyType}"/>
                <apex:column headerValue="From" value="{!h.fr}"/>
                <apex:column headerValue="To"   value="{!h.to}"/>
            </apex:pageBlockTable>
        </apex:pageBlock>
    </apex:form>
</apex:page>

CaseHistoryPrint

<apex:page controller="CaseHistoryCon" showHeader="false" renderAs="pdf">
    <apex:stylesheet value="{!$Resource.printcss}"/>
    <apex:panelGrid columns="2" width="100%">
        <apex:panelGroup >
            <apex:outputText value="Case History Report: {!case.casenumber}" styleClass="title"/>
            <apex:outputText value="{!case.subject}" styleClass="caseDetail"/>
        </apex:panelGroup>
        <apex:image value="{!$Resource.logo}"/>
    </apex:panelGrid>
    <apex:variable value="{!0}" var="m"/>
    <apex:variable value="{!0}" var="d"/>
    <apex:repeat value="{!histories}" var="h">
        <apex:outputPanel layout="block" rendered="{!m != h.dtmonth}" styleClass="month">
            <apex:outputText value="{!h.dtmonthyr}"/>
        </apex:outputPanel>
        <apex:outputPanel layout="block" rendered="{!d != h.dtday}" styleClass="day">
            <apex:outputText value="{!h.dtdayfmt}"/>
        </apex:outputPanel>
        <apex:panelGrid columns="2" styleClass="items" columnClasses="time, data">
            <apex:outputText value="{!h.dttime}"/>
            <apex:panelGroup >
                <apex:outputText value="{!h.actorname}: "/>
                <apex:outputText value="{!h.historyType}"/>
            </apex:panelGroup>
            <apex:outputText value=""/>
            <apex:panelGroup >
                <apex:outputText value="(public) " rendered="{!h.ispublic}"/>
                <apex:outputText value="{!h.fr}:" rendered="{!h.fr <> ''}"/>
                <apex:outputText value="{!h.to}"/>
            </apex:panelGroup>
        </apex:panelGrid> 
        <apex:variable value="{!h.dtmonth}" var="m"/>
        <apex:variable value="{!h.dtday}" var="d"/>
    </apex:repeat>
</apex:page>

printcss

The CaseHistoryPrint page above references a static resource named printcss. This is a simple stylesheet which provides the advanced PDF formatting. The Visualforce pages2PDF capability works with the CSS3 Paged Media module to allow for control of page breaks, output layout, page size and other formatting aspects.

/* This style definition applies for PDF conversion
   and allows for specific formatting within that context */
@page {
	
	/* Landscape orientation */
	size:landscape;
	
	/* Put page numbers in the top right corner of each
	   page in the pdf document. */
	@top-right {
		content: "Page " counter(page);
	}
}

.title {
	font-size:24px;
	font-weight:bold;
	display:block;
}

.caseDetail {
	font-weight:bold;
	font-size:18px;
}

.month {
    border-bottom: 2px solid #000;
	font-size:18px;
}
    
.day {
	margin-left:3em;
	margin-top: 1em;
	border-bottom: 1px solid #000;
	font-size:14px;
}

.items {
	padding-left:5em;
	
	/* Specify preference to not break a page at 
		any element with this style. */
	page-break-before: avoid;
}

.time {
	white-space:nowrap;
}

Controller

The controller is the layer that provides logic, data access and navigation work to your application. In this example the controller layer is comprised of the CaseHistoryCon custom controller, an Apex class.

CaseHistoryCon

/*
 *  The purpose of this class is to take a given case or case record Id and perform a query 
 *  to retrieve the appropriate related objects which will then be added to a collection of
 *  history (apex) objects and ordered chronologically.
 *
 *  This class is used by the caseHistory and caseHistoryPrint Visualforce pages. 
 * 
 *  Note: this class, as per all of Apex, runs in system mode and will currently return all 
 *        records in the system in the query it performs regardless of any sharing rules.
 *        If you want the queries to respect the visibility settings in the application 
 *        (sharing rules) you must append the "WITH SHARING" modifier to the class definition.
 *        Additionally, any fields or objects which are restricted via CRUD or FLS 
 *        (profile security permissions) will be exposed because the binding from the Visualforce 
 *        pages uses the history (apex) object and not the respective SObjects (Case, Task, etc.).
 *        If you wish to have these security settings enforced refer to the Schema.Describe types
 *        in the Apex developer guide for additional information.
 */
public class CaseHistoryCon {
    
    /* Property value that controls the truncation of case comments */
    public boolean fullComments { get; private set; }

    /* Property value that controls visibility of related objects which
       are not visible in the customer portal. */
    public boolean hidePrivate  { get; private set; }

    /* Constructor of the class where we default the above property values */
    public CaseHistoryCon() {
        fullComments           = true;
        hidePrivate            = false;
        truncatedCommentLength = 100;
    }
    
    /* Action method for toggling the fullComments property */
    public void toggleComments() { fullcomments = !fullcomments; }

    /* Action method for toggling the visibility control for private related objects.*/
    public void togglePrivate()  { hidePrivate  = !hidePrivate;  }    
    
    /* Action method for navigating the user back to the case page. */
    public PageReference backToCase() {
        return new ApexPages.StandardController(c).view();
    }

    /* Accessor for retrieving the case object and its related items. If the cid property is null this
       method will return a new, empty case object. The functionality in this method could have been placed
       in the get property accessor for the private property named 'c' below but for simplicity of the page
       author in referencing the current case object this method was created because it is not possible to
       create a variable named 'case' since it is a reserved term in Apex.*/
    public Case getcase() { 
    
        if(cid == null) return new Case();
        return [SELECT casenumber, subject, contact.name, contact.email,
                       (SELECT CreatedBy.Name, CreatedDate, CommentBody,IsPublished          FROM CaseComments ORDER BY CreatedDate      ASC),
                       (SELECT CreatedBy.Name, CreatedDate, Field, NewValue, OldValue        FROM Histories    ORDER BY CreatedDate      ASC),
                       (SELECT CreatedBy.Name, CreatedDate, Name                             FROM Attachments  ORDER BY CreatedDate      ASC),
                       (SELECT Owner.Name, ActivityDateTime, Subject, IsVisibleInSelfService FROM Events       WHERE ActivityDateTime <= :System.Now()   ORDER BY ActivityDateTime ASC),
                       (SELECT Owner.Name, LastModifiedDate, Subject, IsVisibleInSelfService FROM Tasks        WHERE ActivityDate     <= :System.Today() 
                                                                                                                 AND IsClosed = true                     ORDER BY LastModifiedDate ASC)
                FROM case 
                WHERE id = :cid]; 
    }
    
    /* This accessor provides the page with the ordered collection of history (apex) objects for display in the page. 
       it also processes the truncation of case comments as specified by the fullComments property value.*/
    public History[] getHistories() {
        History[] histories = new history[]{};
        for (CaseComment comment:c.casecomments) { 
            if (!hidePrivate || comment.ispublished) {
                addHistory(histories, new history(comment.createdDate, comment.ispublished, comment.createdby.name, 'Comment Added', '' , truncateValue(comment.commentbody))); 
            }
        }
        
        for (Event e:c.events) { 
            if (!hidePrivate || e.isvisibleinselfservice) {
                addHistory(histories, new history(e.activitydatetime,  e.isvisibleinselfservice, e.owner.name,'Event Completed', '' , e.subject)); 
            }
        }
        
        for (Task t:c.tasks) { 
            if (!hidePrivate || t.isvisibleinselfservice) {
                addHistory(histories, new history(t.lastmodifieddate,  t.isvisibleinselfservice, t.owner.name,'Task Completed',  '' , t.subject)); 
            }
        }

        for (CaseHistory ch:c.histories) { 
            addHistory(histories, new history(ch.createdDate, true, ch.createdby.name, ch.field + ' Change', String.valueOf(ch.oldvalue), String.valueOf(ch.newvalue))); 
        }

        for (Attachment a:c.attachments) { 
            addHistory(histories, new history(a.createdDate,  true, a.createdby.name, 'Attachment Added', '' , a.name)); 
        }
        
        return histories;
    }
    
    /* This method adds the newHistory object to the given histories collection in the appropriate order. 
       The order provided here places the oldest records at the front of the list, i.e. by date ascending. */
    private void addHistory(History[] histories, History newHistory) {
        Integer position = histories.size();
        for (Integer i = 0; i < histories.size(); i++) {
            if (newHistory.historydt < histories[i].historydt) {
                position = i;
                break;
            }
        }
        
        if (position == histories.size()) {
            histories.add(newHistory);
        } else {
            histories.add(position, newHistory);
        }
    }
    
    /* Returns the truncated string value if that is specified in the current state (!fullComments)
       and the current length is greater than the value of the private truncatedCommentLength property. */
    private String truncateValue(String s) {
        if (!fullComments && s.length() > truncatedCommentLength) {
            s = s.substring(0,truncatedCommentLength) + '...';
        }
        
        return s;
    }
    
    /* The ID value of the case that will be used by the getCase() method to query for the related
       objects used to generate the ordered history collection. The value will be based on the request 
       parameter, if available. */
    private Id cid { 
        get { 
            if(ApexPages.currentPage().getparameters().get('cid') != null) {
                cid = ApexPages.currentPage().getparameters().get('cid');
            }
            return cid;
        }
        set { 
            if(value != null) cid = value;
        }
    }
    
    /* The case object set by the getCase method and used by the getHistories method to acquire
       the related records.  */
    private Case c { 
        get { return getCase(); }
        set; 
    }
    
    /* The length of "Short Comments" which is used by the truncateValue method in this class to
       truncate case comments when specified by the user. */
    private Integer truncatedCommentLength { get; set; }   
}

Installing this sample

In order to consume this sample within your developer edition or sandbox account you should follow these steps (in order):

  1. Create the History Apex class using the code in the Model section above.
  2. Create the CaseHistoryCon class using the code in the Controller section above.
  3. Create a static resource named "printcss" using the code at the bottom of the View section above. Note: you will need to create a local file with the text and then upload it.
  4. Create a static resource named "images_checkbox" using this file
  5. Create a static resource named "logo" using this file (or any other image you like)
  6. Create the CaseHistory page using the markup above (name it "caseHistory").
  7. Create the CaseHistoryPrint page using the markup above (name it "caseHistoryPrint").

Creation of Apex Classes, Visualforce Pages and Static Resources can be performed under setup from the App Setup > Develop menu.

Alternatively (and preferably) you can also use development mode to create pages and edit them in place in the application. To enable development mode edit your user record under Setup > Personal Setup > My Personal Information > Personal Information, click edit and check the box for "Development Mode" on the right side of the page and save. Now when you navigate to a visualforce page per the instructions below you will see the footer at the bottom of the page where you can make changes to the page and instantly see them in the display area of the browser window.

Using this sample

In order to invoke the caseHistory or caseHistoryPrint pages you can request them with the appropriate case record ID from within your salesforce.com development environment.

It's best to choose a case that has a number of related records in any or all of the Activity History, Case History, Case Comments related lists. Navigate to the case tab within your environment. If you don't see it immediately in your set of tabs you will find it in the "Call Center" application which can be accessed by selecting it from the drop down-menu in the top right portion of any page in the application. If this is your first time to the case tab click on the "Go" button with the "All Open Cases" view highlighted.

Navigate to the appropriate case record.

The URL should look something like: https://<instance>.salesforce.com/<caseID>

Where <instance> is na1, na2, tapp0, etc. and caseID is the record ID for the case you are viewing (should start with "500...")

Update the URL

It should look like this: https://<instance>.salesforce.com/apex/caseHistory?cid=<caseID>

Hit enter and you should see the related records consolidated and ordered per the design of the Visualforce page.

Now update the URL to call the print-optimized PDF page

It should look like this: https://<instance>.salesforce.com/apex/caseHistoryPrint?cid=<caseID>

Hit enter and after a moment your browser should prompt you to view or download a PDF.

Additional usage options

You can also create custom buttons or links from under setup that make this easier for you if you plan to make use of these pages or want to access them often for different cases. Check out the online help (help link in the top right portion of any salesforce.com page) for more information on custom buttons and custom links.

Other considerations

By default, all visualforce pages are restricted to only be accessible to users who can author pages for security. If you want non Visualforce page authors, i.e. those without the "Customize Application" permission on their profile, to be able to access the pages in this sample you need to enable those users' profiles with Page Level Security to the respective pages "caseHistory" and "caseHistoryPrint" which can be managed from either the respective Visualforce page or Profile under setup.

Search for "Visualforce page security" in the online help for additional information. Online help is available by clicking on the help link at the top right of any page within salesforce.com.

Test Class

Given this sample includes apex, the following is an additional class that tests the methods in the custom controller above. This class is not required to install this sample into your sandbox or developer edition org but you can use the code below to create the respective Apex class for reference. If after doing so you'd like to execute the tests in this class you can do so by clicking on the "Run Test" button on the class detail page under setup to see what the test results look like.

Tests can also be executed from the Force.com IDE - see the documentation for installing and using the IDE for additional information.

/* The purpose of this class is to test the caseHistoryCon and history classes.  
   The @IsTest annotation excludes this class from the system cache and as such 
   it is NOT counted against the org code size limit. */
@IsTest private class CaseHistoryConTests {

    /* This is a basic test which simulates the primary positive
       case for the caseHistoryCon controller class as if it were
       invoked by a Visualforce page such as caseHistory or caseHistoryPrint. */
    public static testmethod void basicTest() {
    
        /* Instantiate an instance of this class so we can call it's setup method. */
        CaseHistoryConTests testclass = new CaseHistoryConTests();
               
        /* create a case with the relevant child objects */
        Case c = testclass.setupTestCase();
                
        /* Setup the controller with and test context per the helper method. */
        CaseHistoryCon controller = testclass.setupController(c);
          
        /* Switch to the runtime limit context. */      
        Test.startTest();
               
        /* Simulate the {!histories} expression in the Visualforce page caseHistory
           by directly calling the getHistories method.*/
        List<History> histories = controller.getHistories();   
        
        /* Switch back to the test context.*/
        Test.stopTest();
        
        /* Assert that the history size is the expected value, 5. */
        System.assertEquals(5,histories.size());
        
    }
    
    /* This test calls the actions in the controller. */
    public static testmethod void actionsTest() {
        CaseHistoryConTests testclass = new CaseHistoryConTests();
               
        /* create a case with the relevant child objects */
        Case c = testclass.setupTestCase();
        
        /* Create a page for use in the test. */
        PageReference p = Page.caseHistory;
        
        /* Set the case ID in the context for use by the controller. */
        p.getParameters().put('cid',c.id);
        
        /* Set the page in the test context so the controller will have 
           access to the expected request parameter(s)*/
        Test.setCurrentPage(p);
        
        /* Construct our controller class. */
        CaseHistoryCon controller = new CaseHistoryCon();
        
        /* Assert defaults set by the constructor. */
        System.assert(controller.fullcomments);
        System.assert(!controller.hidePrivate);
        
        /* Call the togglecomments action. */
        controller.toggleComments();       
        
        /* Assert the expected state change. */
        System.assert(!controller.fullcomments);
        
        /* Call the togglePrivate action */
        controller.togglePrivate();
              
        /* Switch to the runtime limit context. */
        Test.startTest();
                
        /* Simulate the {!histories} expression in the Visualforce page caseHistory
           by directly calling the getHistories method.*/
        List<History> histories = controller.getHistories();   
        
        /* Switch back to the test context.*/
        Test.stopTest();
        
        /* The page should only be returning 2 histories in this case (i.e. excluding the private comment, event and task). */
        System.assertEquals(2,histories.size());
        
        /* Assert the expected state change. */
        System.assert(controller.hidePrivate);        
        
        /* Simulate navigation back to the case detail. */
        PageReference result = controller.backToCase();
        
        /* Assert the url for the page reference is as expected from the action. */
        System.assertEquals(result.getUrl(), new ApexPages.StandardController(c).view().getUrl());
    
    }
    
    /* This test simulates the formatting operations in the history class
       that are used by the caseHistoryPrint page. */
    public static testmethod void testHistoryFormatting() {
        
        /* Get the current time for later asserts and history constructor.*/
        Datetime dt = System.now();
        
        /* Construct a history class to test the formatting */
        History h = new History(dt, false, 'actor','history type','from','to');
        
        /* Do the asserts */
        System.assertEquals(h.dtmonthyr,dt.format('MMMMM yyyy'));
        System.assertEquals(h.dttime,dt.format('h:mm a'));
        System.assertEquals(h.dtdayfmt, dt.format('d - EEEE'));
        System.assertEquals(h.dtmonth, dt.month());
        System.assertEquals(h.dtyear, dt.year());
        System.assertEquals(h.dtday, dt.day());
        
    }
    
    /* This setup will be shared across tests.  It creates a basic case,
       2 comments (1 public with long comment, 1 private with short comment), 
       1 closed task in the past, 1 event in the past, and one attachment. */
    private Case setupTestCase() {
    
        /* Get a caseStatus value that is not closed. */
        String status = [Select masterlabel from casestatus where isclosed = false limit 1].masterlabel;
        Case testCase = new Case(Status = status);
        Database.insert(testCase);
            
        /* Create CaseComments */
        List<CaseComment> casecomments = new List<CaseComment>();
        caseComments.add(new CaseComment(ParentId = testCase.id, CommentBody = 'Comment Body', ispublished = false));
        caseComments.add(new CaseComment(ParentId = testCase.id, ispublished = true,
                                         CommentBody = 'Comment Body that is very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very, very long to cause the truncation to occur.'));
                
        Database.insert(casecomments);
        
        /* Create a closed Task (in the past).  Need to first query for a closed status value.*/
        String closedStatus = [select masterlabel from TaskStatus where isclosed = true][0].masterlabel;

        Task t = new Task(whatId = testCase.id, Status = closedStatus, ActivityDate = System.Today().addDays(-1));
        Database.insert(t);
        
        /* Create an Event in the past */
        Event e = new Event(whatId = testCase.id, DurationInMinutes = 60, ActivityDateTime = System.now().addDays(-1));
        Database.insert(e);
        
        /* Create an Attachment */
        Attachment a = new Attachment(ParentId = testCase.id, Name = 'Attachment', Body = Blob.valueOf('Attachment Body'));
        Database.insert(a);
            
        return testCase;
    }
    
    /* A setup method for the tests which constructs a caseHistoryCon class and sets
       the appropriate parameters in the context for tests. */
    private CaseHistoryCon setupController(Case c) {
    
        /* Construct the controller that will be returned by this setup method.*/
        CaseHistoryCon controller = new CaseHistoryCon();
    
        /* Create a page for use in the test. */        
        PageReference p = Page.caseHistory;
        
        /* Set the case ID in the context for use by the controller. */
        p.getParameters().put('cid',c.id);
        
        /* Set the case ID in the context for use by the controller. */
        Test.setCurrentPage(p);
        
        return controller;
    }
}