Change Data Capture(CDC) events :-
This is still a new term in Salesforce world . Do you know how powerful it is ? If not read this post and learn CDC events
Have you ever faced the issue when you are viewing the record and at the same time other user has modified the same record and the data you are seeing is not up to date !! How to tackle the issue ? Here is the solution for that … Let’s start!!
What is CDC ?
- CDC events are basically used to track the changes in a Salesforce record. This means, it tracks create, update, delete and undelete changes of a Salesforce Record, and fires an event when the change occurred.
- You can subscribe to CDC channel to listen for these events. You can even subscribe to these events from you your own platform using javascript libraries like cometD.CDC events are basically used to track the changes in a Salesforce record. This means, it tracks create, update, delete and undelete changes of a Salesforce Record, and fires an event when the change occurred.
- Once you subscribe to these events, you will get a notification whenever a new event is generated, the notification will include a payload in JSON format which will have all the details about the event like:1. SObject name – where the changed happened ex: Account ,opportunity
2. RecordId’s – List of record id’s which are changed
3.Change Type – Create/Update/Delete/Undelete
4.Changed Fields – Fields which are modified
5.User – The User who performed the change
6.Timestamp – When the change happened - You can use this payload to perform your operation. For example, if you want to keep your in-house application in sync with Salesforce Data, you can use payload information to update your application with data changes happened in Salesforce.
Sample Payload
{
"data":{
"event":{
"createdDate":"2018-10-18T01:17:26.855Z",
"replayId":61,
"type":"updated"
},
"sobject":{
"Type":"Customer - Channel",
"Phone":"(785) 241-6201",
"Website":"dickenson-consulting.com",
"Id":"001B000000mAT1ZIAW",
"Name":"Dickenson .inc"
}
},
"channel":"/topic/AccountSpy"
}
CDC Events tracks all changes and all fields in an object and if any of the fields are modified the event will be triggered. This makes it very easy to listen for all type of changes in a record. You can use Streaming API for a similar purpose, but there you specifically need to specify which fields and change type you want to track while creating PushTopics.
Implementation Example
Let’s discuss an implementation scenario of CDC events where you want to keep your UI updated with latest changes
- User “Ron Weasley” is viewing an Opportunity record which is being viewed by the user “Harry Potter” as well at the same time.
- Harry Potter has changed the record data and has modified Opportunity amount.
- Ron Weasley who is unaware of this change and still viewing the old record, has to make a very important decision based on Opportunity amount and send the same to higher management.
- Now in this situation, Ron Weasley may take an incorrect decision and send incorrect data to higher management.
How to avoid the above situation? Well, lets leverage Change Data Capture events and lightning:empApi (Winter’19 release)component to track changes in Opportunity Record, and update the data on Ron Weasley’s screen as soon as the data is modified by Harry Potter. Our coding solution will include below components:
RecordChangeEventHandler Component:
The main component to subscribe, unsubscribe and receive messages from streaming channel of Change Data Capture events. Once this component receives a message, this will fire a component event, which would be handled by the parent component. The channel name is like below:
- Standard Object: “ChangeEvent” appended in the end. Example, AccountChangeEvent, OpportunityChangeEvent etc.
- Custom Object: “__ChangeEvent” appended in the end. Like for custom object “Car__c”, the change event channel name would be “Car__ChangeEvent”
<aura:component access="global"> <!-- ChannelName, which needs to subscribed --> <aura:attribute name="channelName" type="String" required="true"/> <!-- Save the reference of current subscription, which can be unsubscribe later on --> <aura:attribute name="subscription" type="Object"/> <!-- This event is fired when a component is destroyed. Handle this event if you need to do custom cleanup when a component is destroyed.--> <aura:handler name="destroy" value="{!this}" action="{!c.unsubscribe}"/> <!-- init event --> <aura:handler name="init" value="{!this}" action="{!c.subscribe}"/> <!-- empApi component which will be used to subscribe/unsubscribe to a channel --> <lightning:empApi aura:id="empApi"/> <!-- Component event, which will be fired once the message is received This event will be handled by parent component to perform needful action on stream event --> <aura:registerEvent name="onRecordChange" type="c:RecordChangeEvent"/> </aura:component>
RecordChangeEventHandlerController.js :
({ subscribe: function(component, event, helper) { // Get the empApi component. var empApi = component.find("empApi"); // Get the channel name from attribute var channel = component.get("v.channelName"); //fetch latest events var replayId = -1; // Callback function to be passed in the subscribe call. // After an event is received, this callback fire a custom // event to notify parent component and pass payload object var subscribeCallback = function (message) { //Fire the component event to notify parent component var messageEvent = component.getEvent("onRecordChange"); if(messageEvent!=null) { messageEvent.setParam("recordData", message.data); messageEvent.fire(); } //Display event data in browser console console.log("Received [" + message.channel + " : " + message.data.event.replayId + "] payload=" + JSON.stringify(message.data.payload)); }.bind(this); // Register error listener and pass in the error handler function. empApi.onError(function(error){ console.log("Received error ", error); }.bind(this)); // Subscribe to the channel and save the returned subscription object. empApi.subscribe(channel, replayId, subscribeCallback).then(function(value) { console.log("Subscribed to channel " + channel); component.set("v.subscription", value); }); }, unsubscribe : function (component, event, helper) { try{ // Get the empApi component. var empApi = component.find("empApi"); // Get the channel name from attribute var channel = component.get("v.channelName"); // Callback function to be passed in the unsubscribe call. var unsubscribeCallback = function (message) { console.log("Unsubscribed from channel " + channel); }.bind(this); // Error handler function that prints the error to the console. var errorHandler = function (message) { console.log("Received error ", message); }.bind(this); // Object that contains subscription attributes used to // unsubscribe. var subscription = {"id": component.get("v.subscription")["id"], "channel": component.get("v.subscription")["channel"]}; // Register error listener and pass in the error handler function. empApi.onError(function (error) { console.log("Received error ", error); }.bind(this)); // Unsubscribe from the channel using the sub object. empApi.unsubscribe(subscription, unsubscribeCallback); }catch(e){} }, })
RecordChangeEvent Component Event:-
This event will be fired by RecordChangeEventHandler component whenever it receives a message from Change Event. This component event will be handled by the parent component.
<!-- This event will be fired whenever new record change has been captured by RecordChangeEventHandler component --> <aura:event type="COMPONENT" description="Event template"> <aura:attribute name="recordData" type="Object" /> </aura:event>
Record Change Capture:-
This component is parent component which will include actual markup and handle the above-mentioned component event fired my RecordChangeEventHandler component.
<aura:component implements="force:appHostable,flexipage:availableForRecordHome,force:hasRecordId,force:hasSObjectName" access="global" controller="RecordChangeCaptureLightningController"> <aura:attribute name="channelName" type="String" default="" /> <aura:attribute name="autoRefresh" type="String" default="Yes" /> <aura:attribute name="isSupported" type="Boolean" default="false" /> <!--Loading list of supported object for change events - This can be configured in a custom setting or custom metadata type also - however, in that case you need to make a server side call to get the data - which i am trying to avoid here --> <ltng:require scripts="{!$Resource.SupportedObjectsForChangeEvents}" afterScriptsLoaded="{!c.checkCompatibility}" /> <!--Include EmpApiDemo child component and pass channel name to subscribe ex: "/topic/AccountSpy" is my pushtopic channel name once the event is fired, it will handled in handleMessage controller method --> <aura:if isTrue="{!v.isSupported}"> <c:RecordChangeEventHandler channelName="{!v.channelName}" onRecordChange="{!c.handleMessage}" /> <aura:set attribute="else"> <div style="color:red;font-weight: bold;">Record Change Capture does not support this object/record page.</div> </aura:set> </aura:if> </aura:component>
RecordChangeCaptureController.js:-
({ checkCompatibility : function(component, event, helper){ //get current object var objectName = component.get("v.sObjectName"); //Check is object name is not undefined/null if(objectName){ //Get channel name for change event var channelName = helper.getChannelName(objectName); //Check if channel name is not null or undefined if(channelName){ component.set("v.channelName", channelName); //setting supported varibale true component.set("v.isSupported", true); } else{ //Object does not support change event component.set("v.isSupported", false); } } else{ //Object name is undefined or null, hence component will not support this page component.set("v.isSupported", false); } }, /** * Handling the message when change event is fired. * */ handleMessage : function(component, event, helper) { const message = event.getParam('recordData'); const eventType = message.payload.ChangeEventHeader.changeType; const entityName = message.payload.ChangeEventHeader.entityName; const userId = message.payload.ChangeEventHeader.commitUser.substring(0,15); //15 digit id of transaction commit user const signedInUser= $A.get("$SObjectType.CurrentUser.Id").substring(0,15); //15 digit id of logged in user /** * Conditions: * - Change Type should not be create * - Record Id must present in modified recordIds * - User who modified the record should not be logged in user * */ if(!(eventType === "CREATE")){ //Condition 1 - Change type is not "created" Array.from(message.payload.ChangeEventHeader.recordIds).forEach( recordId => { if(recordId === component.get("v.recordId") && !(signedInUser === userId)){ //Condition 2 - Record Id match found && //Condition 3 - commit user is not logged in user //Display console log with changed values console.log(`${eventType} event captured on ${entityName} by user id ${userId}`); /*console.log("Values changed are:"); for(k in message.payload){ if(k){ console.log(`Field Name: ${k} | New Value:${message.payload[k]}`); } }*/ //Now call helper function to get user name and display toast helper.getUser(component, userId, eventType, entityName); } }); } } })
RecordChangeCaptureHelper.js:-
({ /* * This method will call the server side action to get user name * Once user name retrieved, it will show a warning toast to the user * */ getUser : function(component, userId, eventType, entityName) { var action = component.get("c.getUserName"); action.setParams({ "userId" : userId }); action.setCallback(this,function(response) { var state = response.getState(); if (state === "SUCCESS") { // pass returned value to callback function var userName = response.getReturnValue(); this.showToast({ "title":`Record ${eventType}ED`, "type": "warning", "message": `This record has been ${eventType}D by ${userName}` }); //Auto refresh the page to get latest details if auto refresh is selected if(component.get("v.autoRefresh") === "Yes"){ this.autoRefresh(); } } else if (state === "ERROR") { // generic error handler var errors = response.getError(); if (errors) { console.error("Error is getting username: ", errors); } return null; } }); $A.enqueueAction(action); }, /** * Get change event object name from * current page's object * */ getChannelName : function(objectName) { var isSupported = false; //If it is custom object, then it is supported if(objectName.includes("__c")){//Custom Object objectName = objectName.slice(0, -3); //removing __c from the end objectName += "__ChangeEvent"; //appending __ChangeEvent in the end of custom object isSupported = true; } //check if it is one of the supported standard object from static resource else {//Standard Object //iterate over supported object list window.supportedObjectForChangeEvents.forEach(obj => { if(obj.toLowerCase().indexOf(objectName.toLowerCase()) != -1){ //Match found, Object is supported objectName += "ChangeEvent" //appending ChangeEvent in the end of standard object isSupported = true; } }); } if(isSupported === true){//is object supported, return channel name return `/data/${objectName}`; } else{//if object not supported, return null return null; } }, /* * This function displays toast based on the parameter values passed to it * */ showToast : function(params) { var toastEvent = $A.get("e.force:showToast"); if(toastEvent){ toastEvent.setParams(params); toastEvent.fire(); } else{ alert(params.message); } }, /** * Auto refresh the page to get latets details * */ autoRefresh : function(){ var refreshEvent = $A.get('e.force:refreshView'); if(refreshEvent){ refreshEvent.fire(); } else{ console.error("Auto refresh is not supported in current context"); } } })
RecordChangeCapture.design
<design:component> <design:attribute name="autoRefresh" label="Auto refresh the page?" datasource="Yes,No" default="Yes" /> </design:component>
SupportedObjectsForChangeEvents.js:-
//Add standard object list here if any new standard object supports change data events //At the time of winter'19 below object support change data events window.supportedObjectForChangeEvents = ['Account','Asset','Campaign','Case','Contact','ContractLineItem','Entitlement','Lead', 'LiveChatTranscript','Opportunity','Order','OrderItem','Product2','Quote', 'QuoteLineItem','ServiceContract'];
RecordChangeCaptureLightningController.apxc:-
public class RecordChangeCaptureLightningController { @AuraEnabled public static String getUserName(Id userId){ User u = [SELECT Name FROM User WHERE Id=:userId]; return u.Name; } }
Happy Coding!!