Is Visualforce still relevant today or is it a breath away from being a punchline just like its predecessor, S-Controls? Or is it both? Honestly, it doesn’t matter if you’re studying for the Salesforce Platform Developer I (PDI) certification.
Like it or not, Visualforce is still covered on the exam — probably because there are millions of Visualforce pages floating out there in the cloud putting in a good day’s work (and somebody needs to know how to maintain them).
So, in preparation for the PDI exam (that I passed in May 2023!), I took it upon myself to learn a thing or two about Visualforce pages and those krazy controllers. And, as you know, there’s no better way to learn something than by rolling up your sleeves and just building it.
The Requirements
Show a user the opportunities they manually share with others, as well as the opportunities shared with them.
This is from a Trailblazer Community question I came across back in January. The poster wanted to know if there was a way to see all these shared opportunities in one place (instead of drilling down into each record to see if it was shared and with whom). Not surprisingly, I suggested that they run a SOQL query.
But they were looking for something more user-friendly and readily accessible. In addition, the Salesforce object that holds this data, OpportunityShare, is a bit elusive for the average user. For instance, you’re not able to run a report on it. (Update: As of the Winter ’24 release, AccountShare will be made available as a custom Report Type object. Could OpportunityShare be far behind?)
In the end, I recommended a Visualforce page that could be launched from its very own tab. Before looking at the code, let’s break down the requirements even further:
- Information should be presented in tables
- It should only show the running user’s shared opportunities
- It should take into consideration any opportunities shared with public groups the user belongs to
- Opportunities should be separated into those that the user shares with others and those shared with the user
- It should include the sharing method (eg, manual, rule, etc.)
- It should be sorted alphabetically by Account name and then Opportunity name
- It should be presented in the Lightning Experience style and not look clunky or like it came out of Classic
The Controller
When using Visualforce, you need to use a controller. Lucky for us, there’s a built-in controller called a standard controller that requires no additional Apex coding and does a lot of heavy lifting. Unlucky for us, it’s called a standard controller even though it works for both standard and custom objects.
I mention that because there’s another Visualforce controller called a custom controller that also works on both standard and custom objects (but requires custom coding on our part). After you get over the fact that the standard and custom controllers have nothing to do with the type of object they work on then you can start wrapping your head around controller extensions and standard and custom list controllers.
For the job at hand, I needed to go with a custom controller. That’s because I couldn’t use a standard list controller (that handles a set of records) since it’s limited to custom objects and 13 specific standard objects — and OpportunityShare isn’t one of them. (And no, I couldn’t use a custom list controller because, in the end, with a custom list controller you’re essentially passing a custom SOQL query to that same OpportunityShare-hating standard list controller. Gee whiz.)
The Results
Here’s what the custom controller (an Apex class called SharedOppsController) looked like in the end:
public with sharing class SharedOppsController { private String currentUserId = UserInfo.getUserId(); public List<OpportunityShare> getSharedOppsWithOthers() { // Pulls a list of Opportunities SHARED BY the current user (via OpportunityShare) List<OpportunityShare> results = [SELECT Opportunity.Account.Name, Opportunity.Name, Opportunity.StageName, Opportunity.Owner.Name, UserOrGroup.Name, OpportunityAccessLevel, RowCause FROM OpportunityShare WHERE Opportunity.OwnerId = :currentUserId AND UserOrGroupId != :currentUserId ORDER BY Opportunity.Account.Name, Opportunity.Name]; return results; } public List<OpportunityShare> getSharedOppsByOthers() { // Pulls a set of Public Group Ids the current user belongs to Set<Id> currentUserPublicGroups = new Map<Id, sObject>([SELECT GroupId Id FROM GroupMember WHERE UserOrGroupId = :currentUserId GROUP BY GroupId]).keySet(); // Returns a list of Opportunities SHARED WITH the current user (via OpportunityShare) List<OpportunityShare> results = [SELECT Opportunity.Account.Name, Opportunity.Name, Opportunity.StageName, Opportunity.Owner.Name, UserOrGroup.Name, OpportunityAccessLevel, RowCause FROM OpportunityShare WHERE Opportunity.OwnerId != :currentUserId AND (UserOrGroupId = :currentUserId OR UserOrGroupId IN :currentUserPublicGroups) ORDER BY Opportunity.Account.Name, Opportunity.Name]; return results; } }
And here’s what the Visualforce page markup ended up looking like:
<apex:page controller="SharedOppsController" lightningStylesheets="true"> <apex:form> <apex:pageBlock> <apex:pageBlockSection columns="1" title="Opportunities SHARED BY {! $User.FirstName } {! $User.LastName }:"> <apex:pageBlockTable value="{!sharedOppsWithOthers}" var="oppShare"> <apex:column value="{!oppShare.Opportunity.Account.Name}"/> <apex:column headerValue="Opportunity Name"> <apex:outputLink value="/{!oppShare.OpportunityId}" target="_blank"> {!oppShare.Opportunity.Name} </apex:outputLink> </apex:column> <apex:column value="{!oppShare.Opportunity.StageName}"/> <apex:column value="{!oppShare.Opportunity.Owner.Name}" headerValue="Owner Name"/> <apex:column value="{!oppShare.UserOrGroup.Name}" headerValue="Shared With" /> <apex:column value="{!oppShare.OpportunityAccessLevel}" headerValue="Access Level"/> <apex:column value="{!oppShare.RowCause}" headerValue="Sharing Method"/> </apex:pageBlockTable> </apex:pageBlockSection> <apex:pageBlockSection columns="1" title="Opportunities SHARED WITH {!$User.FirstName} {!$User.LastName}:"> <apex:pageBlockTable value="{!sharedOppsByOthers}" var="oppShare"> <apex:column value="{!oppShare.Opportunity.Account.Name}"/> <apex:column headerValue="Opportunity Name"> <apex:outputLink value="/{!oppShare.OpportunityId}" target="_blank"> {!oppShare.Opportunity.Name} </apex:outputLink> </apex:column> <apex:column value="{!oppShare.Opportunity.StageName}"/> <apex:column value="{!oppShare.Opportunity.Owner.Name}" headerValue="Owner Name"/> <apex:column value="{!oppShare.UserOrGroup.Name}" headerValue="Shared With" /> <apex:column value="{!oppShare.OpportunityAccessLevel}" headerValue="Access Level"/> <apex:column value="{!oppShare.RowCause}" headerValue="Sharing Method"/> </apex:pageBlockTable> </apex:pageBlockSection> </apex:pageBlock> </apex:form> </apex:page>
And finally, here’s a screenshot of the Visualforce page in action:
So, what’s happening here is that the running user’s ID is picked up and used in the SOQL queries in the custom controller. In the Visualforce markup, those query results are pulled in and laid out in two tables. Also, I’ve made the opportunity names hyperlinks (which is why they appear blue) and either table can be collapsed so the viewer can concentrate on one list at a time. I also gave it the look and feel of Lightning by adding the lightningStylesheets = “true” attribute to the Visualforce markup after declaring the controller.
It’s worth pointing out that although I used with sharing in my custom controller Apex class, custom controllers always run in system mode (unlike the standard controller which runs in user mode). This means it will not take sharing settings and user field-level restrictions into consideration when accessing and displaying object data. This is one important aspect of Visualforce controllers to remember for the PDI exam and to ensure you don’t display confidential or restricted data to your users.
Finally, in the controller, you can see I took advantage of combining an aggregate SOQL query, a map, and keySet() to create a set of IDs to use as a bind variable (see below). To learn more about this trick, check out the following post: Apex: Leveraging keySet() to Skip Loops & Perform Magic.
// Pulls a set of Public Group Ids the current user belongs to Set<Id> currentUserPublicGroups = new Map<Id, sObject>([SELECT GroupId Id FROM GroupMember WHERE UserOrGroupId = :currentUserId GROUP BY GroupId]).keySet();
Curious about something I wrote or coded above, let me know in the comments and I can explain it. And if you think I missed the mark on something, tell me. I’d love to hear how I could have done (or explained) this better. Thanks!