Yumi Ibrahimzade from the blog Salesforce Time has written a post explaining how to use flow to add a mass delete button to a list view. Pay special attention to the second solution that avoids performing DML inside a loop by using a custom action that can be found in the collection actions here on UnofficialSF
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2021-08-11 14:31:332021-08-11 14:31:36From Yumi Ibrahimzade: Using Flow to Mass Delete Records from List View
Amit Singh from the blog SFDCPanther.com has written a post explaining how to create a LWC that will enable a Rich Text Area component in flow screens Check it out
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2021-06-24 14:33:192021-06-24 14:33:21From SFDCPanther: Rich Text Area component for screen flow
Iโve long been a fan of using invocable methods with flows. Today Iโll use a real-life scenario to explain the importance of using invocable variables as well. Weโll start by looking at the anti-pattern of a standalone invocable method, then we’ll follow best practices and refactor the solution into an invocable method plus invocable variables.
The use case comes from a pro bono project for Playworks. Playworks is the leading national nonprofit leveraging the power of play to transform childrenโs social and emotional health. Theyโre a great organization, and they could definitely use some support.
Playworks tracks each school partnership with an opportunity record. A school might purchase 5 trainings, represented by opportunity product records. For financial purposes, the service completion date is tracked for each training, so each training needs its own opportunity product record. Manual entry can get cumbersome, with some opportunity records having 10 or more opportunity products associated with them.
To address this need, we created the Add Products Wizard. Built using flow, the wizard starts by presenting a user with a list of products to select from.
The user selects the products they want, then enters the quantity and sales price of each product they selected.
So far, so good, but things get tricky if you build an invocable method without invocable variables. Letโs take a look at what not to do, then weโll refactor our solution to follow best practices.
First up, the anti-pattern. Hereโs what the flow loop element looks like behind the scenes, designed for a standalone invocable method. After the user selects their products, the loop begins. The loop runs once for each product selected. The pattern is as follows:
Get the quantity and sales price for each product from the user.
Gather up all needed inputs for the invocable method into a flow text collection variable.
Pass the flow text collection variable into the invocable method.
The invocable method then creates the desired number of opportunity product records.
Clear the values of the text collection variable so the loop can start over.
Then comes the messy part. The text collection variable holds 4 values.
The four values are added to the text collection variable in the following order:
First: Product Id
Second: Opportunity Id
Third: Sales Price
Fourth: Quantity
Imagine having to come in and explain what the opportunityProductInputs variable is holding. There is no way to know, without explicitly inspecting the assignment element that populates the opportunityProductInputs variable. Frustrating, to say the least. The code gets even more wonky. Take a look at the invocable method.
@InvocableMethod(label='Create New Opportunity Products') public static void createNewOpportunityProducts(List<List<String>> inputs){
List<OpportunityLineItem> oppProds = new List<OpportunityLineItem>();
PriceBook2 stdPriceBook = [SELECT Id FROM Pricebook2 WHERE isStandard = True LIMIT 1];
PriceBookEntry pbe = [SELECT Id FROM PriceBookEntry WHERE Product2Id =: inputs.get(0).get(0) AND Pricebook2Id =: stdPriceBook.Id LIMIT 1];
Notice that the data type of the input parameter is a nested list of strings. A standalone invocable method requires that the input parameter be a list. A single string becomes a list of strings, and a list of strings becomes a nested list of strings. Not to mention, you have to use nondescript indexes to reference the inputs within the method. The whole thing gets unwieldy fast. This has major downstream implications for code readability.
Now for a best practice. Letโs refactor this code by pivoting away from a standalone invocable method, and adding some invocable variables to hold the inputs. The invocable variables are defined in an inner class called Requests.
public class Requests{
@invocableVariable(label='Product Id' required=true)
public String productId;
@invocableVariable(label='Opportunity Id' required=true)
public String opportunityId;
@invocableVariable(label='Sales Price' required=true)
public Double salesPrice;
@invocableVariable(label='Quantity' required=true)
public Integer quantity;
}
Next, letโs change the input parameter of the invocable method to accept the data type of the inner class.
@InvocableMethod(label='Create New Opportunity Products') public static void createNewOpportunityProducts(List<Requests> requests)
Hereโs what the refactored method looks like, using the new input parameter.
@InvocableMethod(label='Create New Opportunity Products')
public static void createNewOpportunityProducts(List<Requests> requests){
Requests request = requests[0];
List<OpportunityLineItem> oppProds = new List<OpportunityLineItem>();
Opportunity oppty = [SELECT Playworks_Fiscal_Date__c
FROM Opportunity
WHERE Id =: request.opportunityId];
PriceBook2 stdPriceBook = [SELECT Id
FROM Pricebook2
WHERE isStandard = True
LIMIT 1];
PriceBookEntry pbe = [SELECT Id
FROM PriceBookEntry
WHERE Product2Id =: request.productId
AND Pricebook2Id =: stdPriceBook.Id
LIMIT 1];
for(Integer i = 0; i < request.quantity ; i++){
OpportunityLineItem oppProd = new OpportunityLineItem();
oppProd.Product2Id = request.productId;
oppProd.PricebookEntryId = pbe.Id;
oppProd.OpportunityId = request.opportunityId;
oppProd.UnitPrice = request.salesPrice;
oppProd.Quantity = 1;
oppProds.add(oppProd);
}
insert oppProds;
}
Notice how much more readable the Apex is. Gone are the nondescript indexes, replaced by logical variable names. The Apex action element is also much more readable inside the flow. It is no longer necessary to gather up the inputs with an assignment element. The invocable variable values are set and the invocable method is called in the same place, creating a much more intuitive flow.
When comparing a standalone invocable method to an invocable method plus invocable variables, the difference is clear. The code becomes much more readable, and the flow is much easier to understand in the latter.
There is one more advantage of using an invocable method plus invocable variables, not shown in this example. While a standalone invocable method can only accept a collection of primitive data types, an invocable variable makes it possible to indirectly pass sObjects into the invocable method. An inner class with variables to hold accounts and opportunities would look something like this.
public class Requests{
@invocableVariable(label='Accounts' required=true)
public List<Account> accountsToProcess;
@invocableVariable(label='Opportunities' required=true)
public List<Opportunity> opportunitiesToProcess;
}
Hopefully this post has convinced you not to build a standalone invocable method again, opting instead for an invocable method plus invocable variables. Thanks for reading, and feel free to send me any questions or feedback.
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Andy Andersonhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngAndy Anderson2021-06-01 15:32:142022-10-06 10:33:43Best Practices for Creating Invocable Methods: Using Inner Classes and Invocable Variables
Do you hate food recipe blogs that go on and on about a special story about how they grew up smelling grandmaโs cooking and getting frustrated because you just want to get the recipe? Your kids are crying, the garlic and onions are burning, and you finally get to the recipe but itโs covered by an ad on your phone and the browser crashes.
This is not that post. Letโs get right to it!
I recently wrapped up work on a nifty action called โFindCommonAndUncommonRecordsโ that can compare two like/unlike record collections based on a unique identifier that you specify. It then can provide 4 outputs providing records unique and shared between the two collections based on the identifying fields you provide.
The thoughts and solutions in this post are my own and not that of Salesforce.
Scenario
The action can take a bit of effort to wrap your head around so I created a scenario showing off a hypothetical model involving Accounts and two custom objects, Gas Stations and Gas Station Relationships. An Account can be related to many Gas Stations through the Gas Station Relationships custom object.
Letโs say you build a screen flow that lets the user associate whatever Gas Station they want to the Account (using Datatable or a Lookup component). You will be creating these Gas Station Relationship records to set up that relationship. The catch here is that there are already Gas Stations tied to the account, but you have no way of filtering those out of the datatable/Lookup easily.
The user picks 4 gas stations to associate to Blackbeards Grog Emporium:
Station 17
Station 55
Station 23
Station 3
We then run this new action to get the Gas Stations not tied to the Account, or in other words the records unique to the selected collection by the user.
targetUniqueCollection (Gas_Station__c) – The gas stations unique to the selected gas stations compared to the account’s related gas stations. Returned as Gas Station records.
Station 55
Station 23
Station 3
targetCommonCollection (Gas_Station__c) – The gas stations shared between the account and the selected gas stations. Returned as Gas Station records.
Station 17
sourceUniqueCollection (Gas_Station_Relationship__c) – The gas stations unique to the account compared to the selected gas stations. Returned as Gas Station Relationship records.
Station 45
Station 66
sourceCommonCollection (Gas_Station_Relationship__c) – The gas stations shared between the account and the selected gas stations. Returned as Gas Station Relationship records.
Station 17
From here, you can safely loop over the โtargetUniqueCollectionโ (the unique records selected by the user)to build your junction records without dupes!
Sample Image
This image is a simplified example of two collections being input to the component with four collections being output by the component. The letters represent the value of the designated source or target fields.
Tutorial Video
Inputs
sourceRecordCollectionย – The first collection you want to compare
Type – Record Collection
sourceUniqueID – The field API name / identifier you want to use to find records in the โtargetโ collection (Case Sensitive)
Type – Text
targetRecordCollection – The the second collection you want to compare
Type – Record Collection
targetUniqueID – The field API name / identifier you want to use to find records in the โsourceโ collection (Case Sensitive)
Type – Text
Outputs
sourceUniqueCollection – The unique records in the source collection when compared against the target collection
Type – Record Collection
sourceCommonCollection – The shared records between the two collections, returned as the specified source Output sObject type.
Type – Record Collection
targetUniqueCollection – The unique records in the target collection when compared against the source collection
Type – Record Collection
targetCommonCollection – The shared records between the two collections, returned as the specified target Output sObject type.
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Adam Whitehttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngAdam White2021-02-03 07:31:072023-11-02 07:30:13Compare & Contrast Two Record Collections with ‘FindCommonAndUncommonRecords’
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2020-10-23 06:47:492020-10-26 04:40:08Salesforce Record Automation Benchmarking by Luke Freeland
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2020-10-16 12:49:182020-10-18 12:02:33Use Flows to Track Unread Emails on Cases and Increase Case Management Efficiency by Vibhor Goel
I was recently tasked with a requirement to display a list of opportunity partners on a custom object related to opportunity. I planned to use a flow to get the opportunity partner records and then display them using the Datatablev2 Flow Screen Component available on this site. When I tried to create the flow, I encountered some issues: 1. The Opportunity Partners object is a legacy object and cannot be customized. I could not create formulas for getting the account name or link to the account 2. I could not use dot notation to get the account name from the account ID in the get records component in the flow 3. I could not use the partner account ID as a filter criteria on a new get records element to get the account records.
Not Possible
I knew I had to create a custom way to get the data I needed to display. Eric Smith referred me to his blog post on how to use Apex defined data types and an invocable action to create data for the datatable component. How to Use an Apex-Defined Object with my Datatable Flow Component I create my Apex defined data type for opportunity partners.
/**
* @author : Tamar Erlich
* @date : October 07, 2020
* @description : Wrapper class for opportunity partners
* Note : This class is called by the GetOpportunityPartnersAction.
* *************************************************
* <runTest><runTest>
* *************************************************
* @History
* -------
* VERSION | AUTHOR | DATE | DESCRIPTION
* 1.0 | Tamar Erlich | October 07, 2020 | Created
**/
public with sharing class OpportunityPartnersWrapper {
// @AuraEnabled annotation exposes the methods to Lightning Components and Flows
@AuraEnabled
public String accountName;
@AuraEnabled
public String partnerRole;
@AuraEnabled
public Boolean isPrimary;
@AuraEnabled
public String accountLink;
// Define the structure of the Apex-Defined Variable
public OpportunityPartnersWrapper(
String accountName,
String partnerRole,
Boolean isPrimary,
String accountLink
) {
this.accountName = accountName;
this.partnerRole = partnerRole;
this.isPrimary = isPrimary;
this.accountLink = accountLink;
}
// Required no-argument constructor
public OpportunityPartnersWrapper() {}
}
I then created an invocable action that received the opportunity and account IDs and returns a JSON string that can be displayed in my flow table.
/**
* @author : Tamar Erlich
* @date : October 07, 2020
* @description : Invocable method that given opportunityId and accountId, returns a list of opportunity partners to display on opportunity plan record page
* Note : This class is called by the Flow.
* *************************************************
* <runTest>GetOpportunityPartnersActionTest<runTest>
* *************************************************
* @History
* -------
* VERSION | AUTHOR | DATE | DESCRIPTION
* 1.0 | Tamar Erlich | October 07, 2020 | Created
**/
global with sharing class GetOpportunityPartnersAction {
// Expose this Action to the Flow
@InvocableMethod
global static List<Results> get(List<Requests> requestList) {
// initialize variables
Results response = new Results();
List<Results> responseWrapper = new List<Results>();
String errors;
String success = 'true';
String stringOutput;
List<OpportunityPartner> opportunityPartners = new List<OpportunityPartner>();
List<OpportunityPartnersWrapper> opportunityPartnersSet = new List<OpportunityPartnersWrapper>();
Set<Id> setOppIds = new Set<Id>();
Set<Id> setAccIDs = new Set<Id>();
Map<Id, List<OpportunityPartner>> mapOppOppPartners = new Map<Id, List<OpportunityPartner>>();
// create sets of Ids to use as bind variables in the query
for (Requests rq : requestList) {
setOppIds.add(rq.opportunityId);
setAccIds.add(rq.accountId);
}
// query all request opportunities and their partners and create a map with opportunityId as the key and a list of parrtners as the values
for (Opportunity opp : [
SELECT
Id,
(
SELECT AccountTo.Name, IsPrimary, Role
FROM OpportunityPartnersFrom
WHERE OpportunityId = :setOppIds AND AccountToId != :setAccIds
)
FROM Opportunity
]) {
mapOppOppPartners.put(opp.Id, opp.OpportunityPartnersFrom);
}
for (Requests curRequest : requestList) {
String accountId = curRequest.accountId;
String opportunityId = curRequest.opportunityId;
try {
if (accountId == null || opportunityId == null) {
throw new InvocableActionException(
'When using the GetOpportunityPartners action, you need to provide BOTH an Account Id AND an Opportunity Id'
);
}
if (!accountId.startsWith('001') || !opportunityId.startsWith('006')) {
throw new InvocableActionException(
'Invalid Account or Opportunity ID'
);
}
// get the list of opportunity partners matching the current opportunityId from the map
if (accountId != null && opportunityId != null) {
opportunityPartners = mapOppOppPartners.get(opportunityId);
}
// populate the Apex defined wrapper type with opportunity partner information
for (opportunityPartner op : opportunityPartners) {
OpportunityPartnersWrapper opw = new OpportunityPartnersWrapper();
opw.accountName = op.AccountTo.Name;
opw.partnerRole = op.Role;
opw.isPrimary = op.IsPrimary;
opw.accountLink = '/' + op.AccountToId;
opportunityPartnersSet.add(opw);
}
// Convert Record Collection to Serialized String
stringOutput = JSON.serialize(opportunityPartnersSet);
} catch (InvocableActionException e) {
System.debug('exception occured: ' + e.getMessage());
errors = e.getMessage();
success = 'false - custom exception occured';
} catch (exception ex) {
System.debug('exception occured: ' + ex.getMessage());
errors = ex.getMessage();
success = 'false - exception occured';
}
// Prepare the response to send back to the Flow
// Set Output Values
response.errors = errors;
response.successful = success;
response.outputCollection = opportunityPartnersSet;
response.outputString = stringOutput;
responseWrapper.add(response);
}
// Return values back to the Flow
return responseWrapper;
}
// Attributes passed in from the Flow
global class Requests {
@InvocableVariable(label='Input the Related OpportunityId' required=true)
global String opportunityId;
@InvocableVariable(label='Input the Opportunity\'s AccountId' required=true)
global String accountId;
}
// Attributes passed back to the Flow
global class Results {
@InvocableVariable
global String errors;
@InvocableVariable
global String successful;
@InvocableVariable
public List<OpportunityPartnersWrapper> outputCollection;
@InvocableVariable
public String outputString;
}
// custom exception class
global class InvocableActionException extends Exception {
}
}
/**
* @author : Tamar Erlich
* @date : October 07, 2020
* @description : Test class for GetOpportunityPartnersAction Invocable method
* Note :
* *************************************************
* <runTest>GetOpportunityPartnersActionTest<runTest>
* *************************************************
* @History
* -------
* VERSION | AUTHOR | DATE | DESCRIPTION
* 1.0 | Tamar Erlich | October 07, 2020 | created
**/
@IsTest
public with sharing class GetOpportunityPartnersActionTest {
@isTest
public static void opportunityPartnersFound(){
// initialize variables
List<GetOpportunityPartnersAction.Requests> requestList = new List<GetOpportunityPartnersAction.Requests>();
String accountId;
String opportunityId;
// create test account
List<Account> accounts = new List<Account>();
for (Integer j = 0; j < 1; j++) {
Account a = new Account(
Name = 'email' + j + '.com'
);
accounts.add(a);
}
insert accounts;
// create test opportunity
Date closeDate = System.today();
Opportunity testOpp;
testOpp = new Opportunity(
Name = 'test opp',
StageName = 'Open In-Progress',
Amount = 100,
CloseDate = closeDate
);
insert testOpp;
accountId = accounts[0].Id;
opportunityId = testOpp.Id;
// create test opportunity partner
OpportunityPartner testPartner;
testPartner = new OpportunityPartner(
OpportunityId = opportunityId,
AccountToId = accountId,
Role = 'Dealer',
IsPrimary = false
);
insert testPartner;
// prepare request for GetOpportunityPartnersAction
GetOpportunityPartnersAction.Requests request = new GetOpportunityPartnersAction.Requests();
request.accountId = accountId;
request.opportunityId = opportunityId;
requestList.add(request);
// run test and assert results
List <GetOpportunityPartnersAction.Results> results = GetOpportunityPartnersAction.get(requestList);
System.assertEquals(results[0].errors, null,'Errors not expected');
System.assertNotEquals(results[0].successful, 'false', 'Success expected');
System.assertEquals(true, results[0].outputCollection.size()>0, 'Opportunity Partners expected');
}
}
I then used my new action in the flow to get the opportunity partner records.
Invocable action
I then configured my datatable on the flow screen
Flow Screen
Some notes on the datatable configuration: 1. User Defined had to bet set to true 2. The Datatable Record String must be set to the variable returned from the action 3. The column field types must be set for all columns. I set the first column to the url type 4. My first column has special type attributes for displaying as a clickable link: 1:{label: { fieldName: accountName}, target: โ_blankโ} 5. Since this is for display only, I hid the checkbox columns 6. I used the new features available in v2.46 to apply an icon and title to the table
The View All link uses a formula to create the link
View All Formula
Here is the entire flow
And here is the end result
Opportunity Partners on custom object
This use case can be easily adapted to display other opportunity related information like opportunity team members or opportunity contact roles.
There is now a new Output Parameter that passes back the number of Selected Records. This helps with being able to easily check later in your Flow to see if any records were selected. This feature can also be used to set Component Visibility for components on the same Screen as the Datatable.
Another new Parameter lets you set the Datatable as Required. This will keep the User from advancing past the Screen if no records are selected.
When just a single record is selected in the Datatable, a new SObject Output Parameter will be populated with the single selected record in addition to the standard SObject Collection Output Parameter.
Another new feature for Datatables displaying just a single record is the appearance of a Clear Selection button. This button will be made available once the single record has been selected using the standard Radio Button.
You can now add a Header Icon and Header Label to the entire Datatable.
Additional Enhancements and Bug Fixes include:
Spinners will be displayed when sorting & filtering data
Picklist and Multi-Picklist Labels will be displayed instead of the API Names
The link to the record for the objectโs standard Name field can be suppressed
The Configuration Helper now uses the Flow Base Components version of the DualListBox component
Column Field Names attributes are now recognized with case insensitivity and even if missing the __c suffix
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Eric Smithhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngEric Smith2020-10-07 09:19:432020-12-14 07:46:03New features have just been added to the Datatable component
Trying to report on user login history using standard Salesforce reporting can be tricky. If you would like to see each users’s last login and his total login count for the last 6 months, sorted by login count, you have to use some external Excel manipulation. I was looking to get a simple report I could use to monitor login usage.
Simple Login Report
I was able to provide a nice solution using the new before save flow record trigger. The first step is to create a number field on the user object to hold the login count. Then create this simple before save flow. This flow updates the custom field on the User object. By incrementing a counter every time a login history is generated, each user shows a login count. Then, at any time, you can generate the report by simply reporting on your users.
User Login History FlowGet Login History RecordsConfirm records are returned (Null Check)Use the record count operator to assign the record count to the user fieldIf no records are returned, assign zero login count
This use case can be easily adapted to rollup any related records on other objects. I can think of several examples: count of opportunity contact roles on opportunity, count open cases on a contact, count of won opportunities on an account.
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2020-08-12 15:43:282020-08-12 16:39:53Flow Use Case – Use Before Save flow to update the total login count on the user record
GORAV SETH demonstrates how you can use ISCHANGED and PRIORVALUE in Before Save Flows Check it out
https://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.png00Tamar Erlichhttps://unofficialsf.com/wp-content/uploads/2022/09/largeUCSF-300x133.pngTamar Erlich2020-05-12 12:46:322020-05-12 12:46:33How to use ISCHANGED and PRIORVALUE in Before Save Flows by GORAV SETH