Visualforce Sample - Quote Generation with Pages2PDF

Visualforce can be easily converted to PDF documents automatically—simply add renderAs="PDF" as an attribute to the <apex:page> tag. For related information, check out the Visualforce Developer's Guide.

The following sample scenario, based on the use case of generating a sales quote, highlights this capability. In this example, you will see the two mechanisms by which this functionality can be leveraged:

  1. In the standard salesforce.com user interface by calling the specific Visualforce page
  2. From within Apex where the PDF can be attached to a record or sent as an email attachment


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.

While conversion of a Visualforce page to a PDF document is the primary subject of this example, additional important concepts and capabilities are also highlighted by this example which include:

  • Extending Standard Controllers with Apex to:
    • add behavior to standard actions like save
    • default values in new record forms
    • leverage standard navigation
  • Using property syntax instead of traditional accessor methods, e.g. getProperty(), setProperty(Object p)
  • Using an archive (zip file) as a static resource for including CSS and images in a Visualforce page
  • Using the action attribute on page to handle conditional navigation


Model

The model is the schema or data interface to the application. In this example the Model refers to SObjects (salesforce objects). The SObjects referenced in the model for this example include:

Standard:

  • Account
  • Contact
  • Opportunity
  • OpportunityLineItem
  • Product2
  • PricebookEntry

Custom:

  • Quote__c
  • Quote_Item__c

For your convenience, an unmanaged appexchange package has been created that contains the definitions of the custom objects as well as some additional metadata to help get started with this example. To install this package in your Developer Edition or Sandbox account click on this link.


View

The view layer is the presentation of your information to the user. In this example the view is comprised of three Visualforce pages QuoteNew, QuotePDF and QuoteAttach. The markup for each follows.

Note: Due to dependencies between pages and controllers, extensions and static resources you should follow the ordered instructions in the section named "Installing this sample" found below.


QuotePDF

<apex:page standardController="Quote__c" showHeader="false" renderAs="pdf">
    <body>
        <apex:stylesheet value="{!URLFOR($Resource.pdfresources, 'styles.css')}"/>
        <apex:image value="{!URLFOR($Resource.pdfresources,'logo.gif')}"/>
        <apex:panelGrid columns="1" styleClass="companyTable" width="100%">
            <apex:outputText value="{!$Organization.Name}" styleClass="companyName"/>
            <apex:outputText value="{!$Organization.Street}"/>
            <apex:outputText value="{!$Organization.City}, {!$Organization.State} {!$Organization.PostalCode}"/>
            <apex:outputText value="{!$Organization.Phone}"/>
        </apex:panelGrid>
        <apex:outputPanel layout="block" styleClass="line"/>
        <apex:panelGrid columns="1" styleClass="centered" width="100%">
            <apex:panelGrid columns="2" width="100%" cellpadding="0" cellspacing="0" columnClasses="left,right">
                <apex:outputText value="Quote# {!Quote__c.name}"  styleClass="customerName"/>
                <apex:outputField value="{!Quote__c.lastmodifieddate}" style="text-align:right"/>
            </apex:panelGrid>
            <apex:outputText value="{!Quote__c.opportunity__r.account.name}" styleClass="customerName"/>
            <apex:outputText value="{!Quote__c.contact__r.name}" styleClass="contactName"/>
        </apex:panelGrid>
        <apex:panelGrid columns="1">
            <apex:outputText value="{!Quote__c.opportunity__r.account.name}"/>
            <apex:outputText value="{!Quote__c.contact__r.mailingStreet}"/>
            <apex:panelGroup >
                <apex:outputText value="{!Quote__c.contact__r.mailingCity}"/>
                <apex:outputText value=", {!Quote__c.contact__r.mailingState}"/>
                <apex:outputText value=" {!Quote__c.contact__r.mailingPostalCode}"/>
            </apex:panelGroup>
            <apex:outputText value="Phone: {!Quote__c.contact__r.phone}"/>
        </apex:panelGrid>
        <apex:outputPanel layout="block" styleClass="lineSmall"/>
        <apex:repeat value="{!Quote__c.quote_items__r}" var="line">
            <apex:panelGrid columns="2" columnClasses="left,right" width="100%">
                <apex:panelGroup >
                    <apex:outputText value="{!line.name}" styleClass="productName"/>
                    <apex:outputPanel layout="block" styleClass="productDetail">
                        <apex:panelGrid columns="2" columnClasses="left,none">
                            <apex:outputText value="Units:" style="font-weight:bold"/>
                            <apex:outputField value="{!line.Quantity__c}"/>
                            <apex:outputText value="Unit Price:" style="font-weight:bold"/>
                            <apex:outputField value="{!line.Unit_Price__c}"/>
                            <apex:outputText value="Product Code:" style="font-weight:bold"/>
                            <apex:outputField value="{!line.product__r.productCode}"/>
                            <apex:outputText value="Description:" style="font-weight:bold"/>
                            <apex:outputField value="{!line.product__r.description}"/>
                        </apex:panelGrid>
                    </apex:outputPanel>
                </apex:panelGroup>
                <apex:outputField value="{!line.Total_Price__c}" styleClass="productName"/>
            </apex:panelGrid>
        </apex:repeat>
        <apex:outputPanel layout="block" styleClass="lineSmall"/>
        <apex:panelGrid columns="2" columnClasses="right" width="100%">
            <apex:panelGrid columns="2" cellpadding="10" columnClasses="right totalLabel,right total" width="100%">
                <apex:outputText value="Total"/>
                <apex:outputField value="{!Quote__c.Total_Price__c}"/>
            </apex:panelGrid>
        </apex:panelGrid>
        <apex:outputPanel layout="block" styleClass="line"/>
    </body>
</apex:page>


QuoteAttach

<apex:page standardController="Quote__c" Extensions="quoteExt" action="{!attachQuote}">
    {!Quote__c.Opportunity__c}{!Quote__c.name}
</apex:page>

QuoteNew

<apex:page standardController="Quote__c" extensions="quoteExt">
  <apex:sectionHeader title="Edit Quote" Subtitle="New Quote"/>
  <apex:form >
      <apex:inputHidden value="{!q.Opportunity__c}"/>
      <apex:pageBlock title="Quote Information" mode="edit">
          <apex:pageBlockButtons >
              <apex:commandButton value="Save" action="{!save}"/>
              <apex:commandButton value="Cancel" action="{!cancel}"/>
          </apex:pageBlockButtons>
          <apex:pageBlockSection title="Information" columns="1">
              <apex:inputField value="{!Quote__c.Opportunity__c}"/>
              <apex:inputField value="{!Quote__c.Contact__c}"/>
              <apex:inputField value="{!Quote__c.Description__c}"/>
          </apex:pageBlockSection>
          <apex:pageBlockSection title="Address Information" columns="1">
              <apex:outputField value="{!Quote__c.opportunity__r.account.name}"/>
              <apex:inputField value="{!Quote__c.Street__c}"/>
              <apex:inputField value="{!Quote__c.City__c}"/>
              <apex:inputField value="{!Quote__c.State__c}"/>
              <apex:inputField value="{!Quote__c.Zip_Code__c}"/>
          </apex:pageBlockSection>
      </apex:pageBlock>
  </apex:form>
</apex:page>


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 standard controller for the Quote__c custom object as well as the QuoteExt extension class written in Apex.


QuoteExt

/*
 *  This class which extends the Quote__c standard controller is utilized by the QuoteNew
 *  Visualforce page to default values for the user from the related opportunity during 
 *  the creation of a new quote.  It also provides an action that generates the PDF and creates 
 *  an attachment under the parent Quote record who's id is passed into the same Visualforce page 
 *  that can be accessed in the UI to generate the same PDF.
 */
public class quoteExt {

    /* The standard controller object which will be used later for navigation and to invoke
       it's save action method to create the new Quote. */
    ApexPages.StandardController controller;
    
    /* The quote property which is used by the quoteNew and quotePDF Pages and this controller
       to provide access to the relevant quote information. */
    public Quote__c q {get;set;}
    
    /* The constructor to this extension class which takes the standard controller as its argument
       which allows this class to access the methods and information available in the instance for 
       the quote.*/
    public QuoteExt(ApexPages.StandardController c) {
        controller = c;
        q          = (Quote__c) c.getRecord();
        
        /* Set the quote's lookup field to opportunity to the value of the oppid request parameter. */
        q.opportunity__c = ApexPages.currentPage().getParameters().get('oppid');
        
        /* If non-null, get the opportunity and contact role for appropriate defaulting of values. */
        if(q.opportunity__c != null) {
            /* Set the related opportunity with the result of the query that traverses to account for display of the name
               and down to get the primary contact role. */
            q.opportunity__r = [select name, account.name,
                                       (select contact.name, contact.mailingStreet, contact.mailingcity, contact.mailingstate, 
                                               contact.mailingpostalcode 
                                        from opportunityContactRoles 
                                        where isPrimary = true)
                                from opportunity 
                                where id = :q.opportunity__c];
                                
            if(q.opportunity__r.opportunityContactRoles.size()  == 1) {
                OpportunityContactRole r = q.opportunity__r.opportunityContactRoles[0];
                q.contact__r  = r.contact;
                q.contact__c  = r.contact.id;  
                q.street__c   = r.contact.mailingstreet;
                q.city__c     = r.contact.mailingcity;
                q.state__c    = r.contact.mailingstate;
                q.zip_code__c = r.contact.mailingpostalcode;
                
            }
        }
    }
    
    /* This save method will be called instead of the standard controller save method when bound
       to an action component in an associated page, (apex:commandButton on the quoteNew 
       Visualforce page in this example) */
    public PageReference save() {
    
        /* Invoke the standard controller save method which returns the pageReference class 
           that will be used in the navigation, i.e. send the user to the expected location based on
           standard navigation rules provided by salesforce.com */
        PageReference p = controller.save();
        
        /* The quote's record Id */
        id qid = controller.getId();
        
        
        /* The collection of quote_item__c records to be created based on the related opportunity's 
           opportunity line items (products), if any. */
        Quote_Item__c[] items = new Quote_Item__c[]{};
        
        /* Establish the quoteItem collection based on the opportunity's line items, if any */
        for(OpportunityLineItem oli:[select quantity, unitprice, pricebookEntry.product2.name, pricebookEntry.product2id 
                                     from opportunitylineitem
                                     where opportunityid = :q.opportunity__c]) {
                                     
            items.add(new Quote_Item__c(quantity__c = oli.quantity, unit_price__c = oli.unitprice, quote__c = qid, 
                                        name = oli.pricebookentry.product2.name,product__c = oli.pricebookentry.product2id));
        }
        
        /* If any line items need to be inserted do so here.*/
        if(items.size() > 0) insert items;
        
        /* Return the page reference generated by the standard controller save, which usually drops the user
           on the detail page for the newly created object. */
        return p;
        
    }
    
    /* The action method that will generate a PDF document from the QuotePDF page and attach it to 
       the quote provided by the standard controller. Called by the action binding for the attachQuote
	   page, this will do the work and take the user back to the quote detail page. */
    public PageReference attachQuote() {
        /* Get the page definition */
        PageReference pdfPage = Page.quotePDF;
        
        /* set the quote id on the page definition */
        pdfPage.getParameters().put('id',q.id);
        
        /* generate the pdf blob */
        Blob pdfBlob = pdfPage.getContent();
        
        /* create the attachment against the quote */
        Attachment a = new Attachment(parentId = q.id, name=q.name + '.pdf', body = pdfBlob);
        
        /* insert the attachment */
        insert a;
        
        /* send the user back to the quote detail page */
        return controller.view();
    }
}


Installing this Sample

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

  1. Install this package
  2. Add the Quote related list to your Opportunity page layout
  3. Replace the standard "New" button with the custom "New Quote" button in the Quote related list


Using this Sample

In order to invoke the QuoteNew page you can either add the custom "New Quote" button in the appexchange package to the quote related list on the opportunity layout or simply call the page from within your salesforce.com account as such:

https://<instance>.salesforce.com/apex/quoteNew?oppid=<opportunityId>

Where <instance> is na1, na2, tapp0, etc. and <opportunityId> is the record ID of an opportunity record (ideally one with line items and a primary contact role established)

Complete the form and save it. Notice that the line items were copied over (if the opportunity had line items).

Now click on the "Generate Printable Format" button which will call the Visualforce page which will be converted to a PDF on the server based on the renderAs attribute value on the page component tag being "pdf". Change it to "html" to see it as a web page.

Click on the "Attach Printable Format" button to call the same page programmatically from apex and attach it to the same quote. When the page refreshes you should see an new attachment under the quote.


Test Class

Given this sample includes apex, the following is an additional class that tests the methods in the standard controller extension 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 quoteExt class.  The @IsTest annotation
 * excludes this class from the system cache and as such it is not counted against the
 * org code size limit. NOTE: this test and the sample ASSUMES the organization has 
 * opportunity products enabled and does NOT have multi-currency enabled.
 */
@IsTest private class quoteExtTests {

    /* This is a basic test which simulates the primary positive case for the 
       save method in the quoteExt class. */
    public static testmethod void basicSaveTest() {

        Opportunity o = quoteExtTests.setupTestOpportunity();

        /* Construct the standard controller for quote. */
        ApexPages.StandardController con = new ApexPages.StandardController(new Quote__c());

        /* Switch to runtime context */
        Test.startTest();

        /* Construct the quoteExt class */
        QuoteExt ext = new QuoteExt(con);

        /* Call save on the ext */
        PageReference result = ext.save();

        /* Switch back to test context */
        Test.stopTest();

        /* Verify the navigation outcome is as expected */
        System.assertEquals(result.getUrl(), con.view().getUrl());

        /* Verify the oppty amount is equivalent to the quote amount */
        Decimal opportunityAmount = [select amount from opportunity where id = :o.id].amount;
        Decimal quoteAmount       = [select total_price__c from quote__c where id = :con.getId()].total_price__c;        
        System.assertEquals(opportunityAmount, quoteAmount);

    }

    /* This is a basic test which simulates the primary positive case for the 
       attachQuote method in the quoteExt class. */
    public static testmethod void basicAttachTest() {
        Opportunity o = quoteExtTests.setupTestOpportunity();

        /* Construct the standard controller for quote. */
        ApexPages.StandardController con = new ApexPages.StandardController(new Quote__c());

        /* Construct the quoteExt class */
        QuoteExt ext = new QuoteExt(con);

        /* Call save on the ext */
        ext.save();       

        /* Set the extension quote object using the id on the controller. */
        ext.q = new quote__c(id = con.getId());  

        /* Switch to runtime context */
        Test.startTest();

        /* Simulate the button invocation of the attachQuote action method 
           on the extension. */
        PageReference result = ext.attachQuote();

         /* Switch back to test context */
        Test.stopTest();

        /* Verify the navigation outcome is as expected */
        System.assertEquals(result.getUrl(), con.view().getUrl());

        /* Verify the attachment was created. */
       System.assert([select name from attachment where parentid = :con.getId()].name != null);
    }

    /* This setup method will create an opportunity with line items and a primary
       contact role for use in various tests. */
    private static Opportunity setupTestOpportunity() {

        /* Create an account */
        Account a = new Account();
        a.name    = 'TEST';
        Database.insert(a);

        /* Get the standard pricebook. There must be a standard pricebook already 
           in the target org.  */
        Pricebook2 pb = [select name, isactive from Pricebook2 where IsStandard = true limit 1];

        if(!pb.isactive) {
            pb.isactive = true;
            Database.update(pb);
        }

        /* Get a valid stage name */
        OpportunityStage stage = [select MasterLabel from OpportunityStage limit 1];

        /* Setup a basic opportunity */
        Opportunity o  = new Opportunity();
        o.Name         = 'TEST';
        o.AccountId    = a.id;
        o.CloseDate    = Date.today();
        o.StageName    = stage.masterlabel;
        o.Pricebook2Id = pb.id;

        /* Create the opportunity */
        Database.insert(o);

        /* Create a contact */
        Contact c   = new Contact();
        c.lastname  = 'LASTNAME';
        c.firstname = 'FIRSTNAME';

        Database.insert(c);

        /* Create the opportunity contact role */
        OpportunityContactRole r = new OpportunityContactRole();
        r.ContactId     = c.id;
        r.OpportunityId = o.id;
        r.IsPrimary     = true;
        r.role          = 'ROLE';

        Database.insert(r);

        /* Create a product2 */
        Product2 p = new Product2();
        p.Name     = 'TEST';

        Database.insert(p);

        /* Create a pricebook entry. */
        PricebookEntry pbe = new PricebookEntry();
        pbe.Pricebook2Id = pb.id;
        pbe.Product2Id   = p.id;
        pbe.IsActive     = true;
        pbe.UnitPrice    = 1;

        Database.insert(pbe);

        /* Create a line item */
        OpportunityLineItem i = new OpportunityLineItem();
        i.opportunityId       = o.id;
        i.pricebookentryid    = pbe.id;
        i.quantity            = 1;
        i.unitprice           = 1;

        Database.insert(i);

        /* Set up the opportunity with the related records */
        r.Contact        = c;
        r.Opportunity    = o;
        o.Account        = a;
        i.Opportunity    = o;
        pbe.Product2     = p;
        pbe.Pricebook2   = pb;
        i.PricebookEntry = pbe;

        /* Set the request parameter that the constructor for quoteExt is expecting */
        PageReference pref = Page.quoteNew;
        pref.getParameters().put('oppid',o.id);
        Test.setCurrentPage(pref);

        return o;
    }
}