Overriding the Opportunity Contact Role in Salesforce Lightning

Need an override for Salesforce out of the box OCR functionality in Lightning – read on… As we all are painfully aware, the standard Salesforce out of the box functionality for Opportunity Contact Role (OCR) is woefully limiting and frustrating. No triggers allowed on these junction red-headed stepchild and just awkward all around. And yet the need to customize them occurs over and over again. I recently had a need to override the OCR functionality in an org that had made the switch to Lightning Experience (LEX). In this article, I share some gems of wisdom I developed during the process. So without further ado… let’s get started.

 

Lightning Components

For this solution, you will need to create custom Lightning components and potentially Quick Actions if your User Cases allow for the creation of an Opportunity from a Contact. Here is a table of required components

 

New Opportunity Quick Action on Contact

Let’s start with the simplest customization and work our way down into the trenches. Create a Quick Action on Contact for Creating an Opportunity. This can be easily achieved with the following steps.

  • Remove standard New Opportunity button from Contact page layout and Opportunity related list.
  • Remove standard New Opportunity quick actions from Lightning actions.
  • Create custom field on Opportunity
    • Type = Lookup to Contact
    • Name = First Contact
    • Required = No
    • Default value = None
    • No need to add to page layout
  • Write a trigger on Opportunity that fires on before/after insert AND First Contact is not null – perform custom logic for adding an OCR. When finished processing the record, empty out First Contact to avoid recursion.
  • Create custom Quick Action for Contact object with this configuration

New Opportunity Contact Role

NOTES
  1. If you are using Record Types, you will need to ensure you include that field on your page. If you can default it to
  2. Preset values: You can preset values here.
    1. Account – Contacts Account is pre-populated
    2. First Contact – Custom field on Opportunity that is a Lookup to Contact record that initiated action. Important as it gives you a hook and insight into the creation of the Opportunity from the Contact so you can perform any OCR custom logic inside an Opportunity trigger for this first ‘OCR’.

The action should look like this

Quick Action Layout Opportunity Contact Role

OCR Related List on Opportunity

Just like we don’t want to create an Opportunity from a Contact without our hook, we don’t want to create an OCR from an Opportunity without the ability to apply our customizations. But for OCRs, we cannot just remove the New button on that related list – we need to remove the entire related list and create our own version of it, adding a Tab to the Opportunity page layout. First let’s create our component that mimics the standard OCR list.

Here is the standard OCR related list that we are copying

OCR Related List Override Opportunity Contact Role

Here is the custom Lightning component, controller and helper to replicate the above with ability to manage our OpportunityContactRoles.

OpportunityContactRoles.cmp
<!--
 - Created by bsullivan on 6/24/18.
 -->

<aura:component description="OpportunityContactRoles" implements="flexipage:availableForAllPageTypes,force:hasRecordId,force:appHostable" access="global"
                controller="ContactRolesCtrl">

    <aura:handler name="init" value="{!this}" action="{!c.init}"/>
    <aura:attribute type="OpportunityContactRole[]" name="ocrs"/>
    <aura:attribute type="String" name="viewAllOcrsUrl"/>

    <aura:attribute name="url" type="String"/>
    <aura:attribute name="pageReference" type="Object"/>
    <lightning:navigation aura_id="navService"/>

    <aura:attribute name="headerTitle" type="Aura.Component[]">
        <h2>
            <b>Contact Roles ({!v.ocrs.length})</b>
        </h2>
    </aura:attribute>

    <lightning:card title="{!v.headerTitle}"
                    iconName="standard:contact" class="outer_card_header">
        <aura:set attribute="actions">
            <lightning:button label="Manage Contact Roles" class="slds-m-vertical--small" onclick="{!c.manageRoles}"/>
        </aura:set>

        <lightning:layout multipleRows="true">
            <aura:iteration items="{!v.ocrs}" var="ocr">
                <lightning:layoutitem size="6">
                    <lightning:card iconName="standard:contact" class="slds-m-right--small">
                        <aura:set attribute="title">
                            <a href="{!'/lightning/r/' + ocr.Contact.Id + '/view'}">{!ocr.Contact.Name}</a>
                            <aura:if isTrue="{!ocr.IsPrimary}">
                                <span class="slds-m-around--medium">
                                    <lightning:badge label="Primary"/>
                                </span>
                            </aura:if>
                        </aura:set>
                        <aura:set attribute="actions">
                            <lightning:buttonMenu  onselect="{!c.handleSelect}" alternativeText="More Options">
                                <lightning:menuItem value="{!ocr.Id}" label="Delete"/>
                            </lightning:buttonMenu>
                        </aura:set>
                        <dl class="slds-dl_horizontal slds-p-vertical--none">
                            <dt class="slds-dl_horizontal__label">Role:
                            </dt>
                            <dd class="slds-dl_horizontal__detail slds-tile__meta">{!ocr.Role}
                            </dd>
                            <dt class="slds-dl_horizontal__label">Title:
                            </dt>
                            <dd class="slds-dl_horizontal__detail slds-tile__meta">{!ocr.Contact.Title}
                            </dd>
                        </dl>
                    </lightning:card>
                </lightning:layoutitem>
            </aura:iteration>
        </lightning:layout>

        <aura:set attribute="footer">
            <a href="{!v.viewAllOcrsUrl}">View All</a>
        </aura:set>

    </lightning:card>
</aura:component>
OpportunityContactRoles.controller
({
    init: function(component, event, helper) {
        helper.doInit(component);
    },
    manageRoles : function(component, event, helper) {
        helper.doManageRoles(component, event);
    },
    handleSelect : function(component, event, helper) {
        helper.doHandleSelect(component, event);
    }
})
OpportunityContactRoles.helper
({
    doInit : function(component) {
        var action = component.get('c.getOcrs');
        var oppId = component.get('v.recordId');

        console.log('====> oppId = ' + oppId);
        action.setParams({
            'oppId' : oppId
        })

        action.setCallback(this,function(result) {
            if(!this.handleResponse(result)){
                return;
            }
            console.log('=====> ocrsJSON = ' + JSON.stringify(result.getReturnValue().ocrsJSON));
            component.set('v.ocrs', JSON.parse(result.getReturnValue().ocrsJSON));
        });

        $A.enqueueAction(action);

        // set url for view all OCRs
        var url = "/apex/ContactRoles?id=" + oppId;
        component.set('v.viewAllOcrsUrl', url);
        console.log('======> v.viewAllOcrsUrl = ' + url);

        var navService = component.find("navService");
        // Sets the route to /lightning/o/Account/home
        var pageReference = {
            type: 'standard__objectPage',
            attributes: {
                objectApiName: 'Account',
                actionName: 'home'
            }
        };
        console.log('=====> pageReference = %o', pageReference);
        component.set("v.pageReference", pageReference);
        // Set the URL on the link or use the default if there's an error
        var defaultUrl = "#";
        navService.generateUrl(pageReference)
            .then($A.getCallback(function(url) {
                console.log('=====> url = ' + url);
                component.set("v.url", url ? url : defaultUrl);

            }), $A.getCallback(function(error) {
                console.log('=====> ERROR = %o', error);
                component.set("v.url", defaultUrl);
            }));
    },
    doManageRoles : function(component, event) {
        var oppId = component.get("v.recordId");
        var url = "/apex/ContactRoles?id=" + oppId;

        console.log('=====> doManageRoles(). url = ' + url);
        var urlEvent = $A.get("e.force:navigateToURL");
        // lets see if we are operating in LEX or SF1, if not, then use straight JS
        if(urlEvent) {
            urlEvent.setParams({
                "url":url,
                "isredirect" : true
            });
            urlEvent.fire();
        } else {
            window.location = url;
        }
    },
    goToContact : function(component, event) {
        var oppId = component.get("v.recordId");
        var url = "/apex/ContactRoles?id=" + oppId;

        console.log('=====> goToContact(). url = ' + url);
        var urlEvent = $A.get("e.force:navigateToURL");
        // lets see if we are operating in LEX or SF1, if not, then use straight JS
        if(urlEvent) {
            urlEvent.setParams({
                "url":url,
                "isredirect" : true
            });
            urlEvent.fire();
        } else {
            window.location = url;
        }
    },
    doHandleSelect : function(component, event) {

        // var action = component.get('c.editOcr');
        //     action = component.get('c.deleteOcr');
        // }
        var ocrId = event.getParam("value");
        var action = component.get('c.deleteOCR');
        var oppId = component.get('v.recordId');

        console.log('====> ocrId = ' + ocrId);
        action.setParams({
            'oppId' : oppId,
            'ocrId' : ocrId,
        })

        action.setCallback(this,function(result) {
            if(!this.handleResponse(result)){
                return;
            }
            component.set('v.ocrs', JSON.parse(result.getReturnValue().ocrsJSON));
        });

        $A.enqueueAction(action);
    },
    handleResponse : function(response, mode) {
        try
        {
            var state = response.getState();
            if (state !== "SUCCESS")
            {
                var unknownError = true;
                if(state === 'ERROR')
                {
                    var errors = response.getError();
                    if (errors)
                    {
                        if (errors[0] && errors[0].message)
                        {
                            unknownError = false;
                            this.showError(errors[0].message, mode);
                        }
                    }
                }
                if(unknownError)
                {
                    this.showError('Unknown error from Apex class', mode);
                }
                return false;
            }
            else if(response.getReturnValue() != undefined){
                var r = response.getReturnValue();
                if(r.hasOwnProperty('auraerror')){
                    this.showError(r.auraerror, mode)
                    return false;
                }
            }
            return true;
        }
        catch(e)
        {
            this.showError(e.message, mode);
            return false;
        }
    },
    showError : function(message, mode){
        this.showToast('Error',message, mode || 'sticky')
    },

    showSuccess : function(message, mode){
        this.showToast('Success',message, mode)
    },

    showToast : function(ttype, message, mode) {
        var toastEvent = $A.get("e.force:showToast");
        toastEvent.setParams({
            "type": ttype,
            "mode": mode || "dismissible",
            "message": message
        });
        toastEvent.fire();
    }
})
ContactRolesExt.apex
/* 
 * ContactRolesExt
 * Created On: 06/24/2018
 * Created By: OpFocus (Brenda Finn)
 * Description: Controller extension for ContactRoles page. Enables additional save logic
 */
public with sharing class ContactRolesExt {

    private static String closedWonStage;
    private static String closedLostStage;
    
    /**
     * inits existing contact roles for opportunity
     */
    public void getOCRs() {
        initLists();
        mapOCRs = new Map<Id, OpportunityContactRole>([Select Id, Role, Opportunity.RecordType.DeveloperName, Opportunity.StageName,
            ContactId, Contact.Contact_Status__c, Contact.Name, IsPrimary, Contact.Email,
            Contact.Phone, Contact.AccountId, Contact.Account.Name from OpportunityContactRole where OpportunityId =:opp.Id order by Contact.Name]);
        listOCRs = mapOCRs ==  null ? new List<OpportunityContactRole>() : mapOCRs.values();
        for(OpportunityContactRole cr :listOCRs) {
            if(cr.IsPrimary) {
                noPrimary = false;
            }
        }
    }
    
    @AuraEnabled
    public static AuraResults getOcrs(Id oppId) {
        AuraResults results = null;
        try {
            System.debug('=======> oppId = ' + oppId);
            List<OpportunityContactRole> listOCRs = [Select Id, Role, Opportunity.RecordType.DeveloperName, Opportunity.StageName, ContactId, Contact.Contact_Status__c,
                Contact.Name, IsPrimary, Contact.Email, Contact.Title, Contact.Phone, Contact.AccountId, Contact.Account.Name from OpportunityContactRole
                where OpportunityId =:oppId order by Contact.Name];
            results = new AuraResults(true, listOCRs);
        } catch (Exception exc) {
            System.debug('======> EXCEPTION = ' + exc.getMessage() + ', Stack  Trace = ' +
                exc.getStackTraceString());
            return new AuraResults('There was an error getting Opportunity Contact Roles.');
        }
        return results;
    }


    /**
     * deletes a single contact role.  Called to delete Contact Role from Opp Related List
     */
    @AuraEnabled public static AuraResults deleteOCR(String oppId, String ocrId) {
        AuraResults results = null;
        try {
            closedWonStage = [select MasterLabel from OpportunityStage where IsClosed=true and IsWon=true limit 1].MasterLabel;
            closedLostStage = [select MasterLabel from OpportunityStage where IsClosed=true and
                IsWon=false and ApiName='Closed Lost' limit 1].MasterLabel;
    
            delete [Select Id from OpportunityContactRole where Id =:rid];
            //TODO: do special processing on deletion of OCR

            results = new AuraResults(true,[Select Id, Role, Opportunity.RecordType.DeveloperName, Opportunity.StageName, ContactId, Contact.Contact_Status__c,
                Contact.Name, IsPrimary, Contact.Email, Contact.Title, Contact.Phone, Contact.AccountId, Contact.Account.Name from OpportunityContactRole
                where OpportunityId =:oppId]);
        } catch (Exception exc) {
            System.debug('======> EXCEPTION = ' + exc.getMessage() + ', Stack  Trace = ' +
                exc.getStackTraceString());
            return new AuraResults('There was an error deleting Opportunity Contact Role.');
        }
        return results;
    }

    /**
     * Class to hold results of all controller methods.
     */
    public class AuraResults {
        @AuraEnabled public Boolean success;
        @AuraEnabled public String auraerror;
        @AuraEnabled public String ocrsJSON;
        
            public AuraResults(Boolean status, List<OpportunityContactRole> ocrs) {
            this.success = status;
            this.ocrsJSON = JSON.serialize(ocrs);
        }
        
        public AuraResults(String auraerror) {
            this.success = false;
            this.auraerror = auraerror;
            this.ocrsJSON = JSON.serializePretty(new List<OpportunityContactRole>());
        }
    }
}

Putting it all together – you have a component that looks very similar to the original save a little extra spacing – pesky javascript wouldn’t behave.

OCR Related List Override

All you need to do now is remove the old one from the page layout and drag and drop the new Lightning component into your detail panel, give it a title.

  1. On the Opportunity detail record page, click on the Gears icon
  2. Click ‘Edit Page’. The Lightning App Builder should open
  3. Within the Lightning App Builder, select the Details panel.
  4. On the right-hand side, click ‘Add Tab’ button. Depending on how many Tabs you already have, it may show up under the More button and will default to value of Details.
  5. Find newly created Tab and select it from within Details panel.
  6. In right-hand side, update Tab label to ‘Opportunity Contact Roles’
  7. Find your Opportunity Contact Roles Lightning component and drop it in new tab ‘Add Component(s) Here’.
  8. Save your changes and return to Opportunity record detail page.

 

Final Steps

The final steps are for you to do –

  1. Provide edit capability from OCR tab we created for individual OCR record.
  2. Provide an override for the Manage Contact Roles (Add/Edit) page that allows the User to add and edit OCR records for the Opportunity.
  3. Provide an override for the View All option from OCR tab to view all OCR records.

I will leave this last step up to you to complete! Happy Coding! Code along with more of my Salesforce developer blogs or you can contact us for you custom Salesforce Lightning development needs.