Voting Component on records in Salesforce: A Guide (LWC)

Voting Component on records in Salesforce: A Guide (LWC)

I saw this question on Trailhead and thought it was quite thought-provoking. The user asked if there were any options to implement 'Voting' on objects in Salesforce, similar to how voting is handled on Salesforce's Idea Exchange (free plug to an idea I want more upvotes on - https://ideas.salesforce.com/s/idea/a0B8W00000Gdb7OUAR/criteria-based-sharing-rules-allow-lookupformuladynamic-values-and-user-field)

So let's make it! Here is an example of the voting screen offered on the idea I linked above. This voting component offers the following functionality:

  1. I can upvote a post
    1. Upvoting it causes the Upvote button to appear green, even after a refresh event.
    2. Upvoting it causes the 'points' view to increase by 10 (each vote is worth 10 points).
  2. I can 'remove' an upvote or downvote
    1. 'Removing' a vote returns both buttons to their neutral colors, and removes the points associated with the vote
  3. I can downvote a post
    1. Downvoting causes the downvote button to appear in red, and causes the points to drop by 10
  4. Total votes are tallied against each user with a vote
    1. Removing a vote (neutral) removes me from the count of 'Votes'
  5. Votes cause real-time updates to score, and score is not influenced by other voters until a refresh occurs
    1. This is common on other apps too, like YouTube. If I were to 'like' a video at 22 upvotes and see it suddenly jump to 35 upvotes, I might be confused. In the same way on Salesforce, I only can influence the score up or down from my user for the duration of time before I refresh.

Some really neat stuff, but how close can we get with a moderate amount of effort? Some things we will need for our solution.

Solution:

We will need to:

  1. Create an object that has a Master-Detail relationship with the object we are voting on, as well as a lookup to the User object
  2. Create 2 Rollup summary fields on the object to track score and vote counts
  3. Create a Lightning Web Component or Flow to display voting information and allow for votes to be cast

Option 1: LWC

Alright, let's get to it!

Step 1: Creating the object: I created an object called Opportunity_Voting__c (as I ran my voting off of the Opportunity object). Name yours whatever you'd like to! Just make sure to:

  1. Create an MD relation to the object you are looking to vote on
  2. Create a lookup to the User object
  3. Create a Type__c lookup
    1. I gave mine the values Upvote, Neutral, and Downvote
  4. Custom formula field (Points__c, of type Number)
    1. I am using the formula to pull the points off of the Type field like so
      1. IF(TEXT(Type__c)='Neutral',0,IF(TEXT(Type__c)='Upvote',10,-10))

On the object you are voting on (for me this is the Opportunity), create the following fields:

  1. Rollup Summary field: SUM of Points

That's all for object and field creation. Now let's get into the fun stuff, the code!

Step 2: Creating a Lightning Web Component

First, I had to create a base that looks something like the Card component from the ideas site, here is what mine looks like:

<template>
    <lightning-card icon-name="utility:approval" variant="base">
      <div slot="title">
          Voting
      </div>
      <div class="idx-idea-info-container">
        <div class="slds-grid slds-gutters">
            <div class="slds-col">
                <lightning-button label="Upvote" title="Upvote" icon-name="utility:like" class="slds-float_right slds-m-left_x-small" variant={upvoteVariant} onclick={upvote}></lightning-button>
            </div>
            <div class="slds-col">
                <lightning-button label="Downvote" title="Downvote" icon-name="utility:dislike" class="slds-float_left slds-m-left_x-small" variant={downvoteVariant} onclick={downvote}></lightning-button>
            </div>
          </div>
      </div>
      <div class="slds-m-top_small" style="text-align: center;">
        <span style="color: #080707;
        font-size: 18px;
        font-weight: bold;
        letter-spacing: 0;
        line-height: 21px;
        text-align: center;">{score} Points</span>
      </div>
    </lightning-card>
</template>

aVotingComponentLWC.html

Before I give the JS code, here is what this component does:

  1. On load, retrieves:
    1. The current score of the record you are on (via Score__c, the rollup summary field)
    2. The user's previous vote, if there was one
      1. If the user has previously voted, the most recent vote is captured and used to automatically fill the button.
  2. On vote:
    1. Checks where the vote came from, and what the previous button config is (effectively, for all use cases, like Upvoting a previously neutral vote, or Downvoting a previously Upvoted record. Also, you can remove votes and return to Neutral)
    2. Immediately updates onscreen
    3. Calls out to Apex, figures out if there is already a voting object for this user/record id combination
      1. If not, we create a new vote record
      2. If it finds one, we update the original vote record
    4. Determines the correct statuses for the buttons

Here's the code:

//Made by Davis <3

import { api, LightningElement } from 'lwc';
import getScore from '@salesforce/apex/aVotingComponentController.getScore';
import vote from '@salesforce/apex/aVotingComponentController.vote';
import getCurrentButtonStatus from '@salesforce/apex/aVotingComponentController.getCurrentButtonStatus';


export default class AVotingComponentLWC extends LightningElement {
    @api recordId;
    data;
    score;
    currentStatus;
    upvoteVariant;
    downvoteVariant;

    connectedCallback(){
        console.log("connected callback");
        getScore({recordId : this.recordId})
        .then(result => {
            console.log(result);
            this.score = result;
            this.error = undefined;
        })
        .catch(error => {
            this.error = error;
        });
        getCurrentButtonStatus({recordId : this.recordId})
        .then(result => {
            console.log(result);
            if(result == null){
                this.upvoteVariant = 'neutral';
                this.downvoteVariant = 'neutral';
            }
            else if(result == 'Upvote'){
                this.upvoteVariant = 'success';
                this.downvoteVariant = 'neutral';
            }
            else if(result == 'Downvote'){
                this.upvoteVariant = 'neutral';
                this.downvoteVariant = 'destructive';
            }
            else{
                this.upvoteVariant = 'neutral';
                this.downvoteVariant = 'neutral';
            }
        })
        .catch(error => {
            this.error = error;
        });
   }    
   upvote(){
    if(this.upvoteVariant == 'success'){
        this.handleVoting('Neutral');
        this.score = this.score - 10;
        this.downvoteVariant = 'neutral';
        this.upvoteVariant = 'neutral';
    }
    else{
        this.handleVoting('Upvote');
        if(this.downvoteVariant == 'destructive'){
            this.score = this.score + 20;
        }
        else{
            this.score = this.score + 10;
        }
        this.downvoteVariant = 'neutral';
        this.upvoteVariant = 'success';
    }
   }
   downvote(){
    if(this.downvoteVariant == 'destructive'){
        this.handleVoting('Neutral');
        this.score = this.score + 10;
        this.downvoteVariant = 'neutral';
        this.upvoteVariant = 'neutral';
    }
    else{
        this.handleVoting('Downvote');
        if(this.upvoteVariant == 'success'){
            this.score = this.score - 20;
        }
        else{
            this.score = this.score - 10;
        }
        this.downvoteVariant = 'destructive';
        this.upvoteVariant = 'neutral';
    }
   }
   handleVoting(votetypeString){
    vote({recordId : this.recordId, voteType : votetypeString})
    .then(result => {
        console.log();
        this.error = undefined;
    })
    .catch(error => {
        this.error = error;
    });

   }
}

aVotingComponentLWC.js

Cool! Now let's check out the Apex. I probably (and definitely should have) utilized @wire and some other quicker ways to reach out to Apex, but I'll leave that as an optimization for a later date 🙃

//Made by Davis <3

public with sharing class aVotingComponentController {
    @AuraEnabled
    public static Integer getScore(String recordId) {
        return (Integer)[SELECT Voting_Score__c FROM Opportunity WHERE Id = :recordId].Voting_Score__c;
    }
    @AuraEnabled
    public static String getCurrentButtonStatus(String recordId) {
        List<Opportunity_Vote__c> oppVoteList = [SELECT Type__c FROM Opportunity_Vote__c WHERE Opportunity__c = :recordId AND User__c = :UserInfo.getUserId()];
        if (oppVoteList.size() > 0){
            return oppVoteList[0].Type__c;
        }
        return null;
    }
    @AuraEnabled
    public static void vote(String recordId, String voteType){
        List<Opportunity_Vote__c> oppVoteList = [SELECT Type__c FROM Opportunity_Vote__c WHERE Opportunity__c = :recordId AND User__c = :UserInfo.getUserId()];
        if (oppVoteList.size() > 0){
            oppVoteList[0].Type__c = voteType;
            update oppVoteList;
        }
        else{
            Opportunity_Vote__c newOppVote = new Opportunity_Vote__c(
                Opportunity__c = recordId,
                User__c = UserInfo.getUserId(),
                Type__c = voteType
            );
            insert newOppVote;
        }
    }
}

aVotingComponentController.cls

And that's really it! I also exposed the component and allowed it for record pages:

<isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
    </targets>

Here are some screenshots:

Upvoting
Downvoting

Option 2: Flow

Using the exact same object model, let's do it in Flow (I'm not going to make it look as cool over there though).

Essentially, the idea is to:

  1. Get a recordId from the page
  2. Get the Opportunity (to pull the score)
    1. I don't want to use $Record here as I need to re-poll for the Opportunity data anyway.
  3. Get any Opportunity_Vote__c records that are already created for the user (to know where to put the default radio button value)
  4. Show a screen with the score on it, allow users to vote via radio button
  5. Run logic to either:
    1. Update an existing Vote object if one is found for the pair of Opp/User with the value from the radio buttons
    2. Create a new Vote object if we don't find one with the value of the radio buttons
  6. Loop back to the Opp to pull the new score, and use the (now 100% created) Vote to set the default value on the radio button
Voting in Flow

So there are two ways to essentially vote on records in your Org! Works pretty well from my testing, and can be an impressive bit of custom functionality when deployed in the right spot. It would be really easy to add a user count to this as well by adding another rollup summary field to the object you are voting on that counts the number of votes that are NOT neutral.