Flows in Salesforce is a great feature which allows us to automate functionality, create actions and build wizards for users to support their processes. Flows allow us to build those actions and wizards using a point and click flow builder tool. This means we can give value so that our users can easily and quickly make small changes to those wizards and actions when needed.
The building blocks of flows are very powerful, they allow us to implement a different kind of functionality quickly. We can create different screens, get the right data from the system, manipulate that data (when needed) and more.
However, flows cannot do everything. There is a limit to the different types of user interface elements we can put on our flow screens and there is a limit to the actions we can make our flow do.
The good news is that we can use Lightning Components in our flows to enhance the user interface in the different flow screens. We can also use Apex to create advance functionality which flow can invoke. We’re going to focus on enhancing the user interface using Lightning components in this blog post.
We’ll take a common use case in which we experienced recently. We created a flow for a sales team we’re working with. We planned and tried to automate a process for their sales executives when they’re selecting a primary quote for their opportunity. The requirement was to allow sales execs to select the primary quote for an opportunity and then give the sales exec the option to change the status of the opportunity, quote, and reject the other quotes on the opportunity.
Usually, this would require the sales executive a lot of clicks in Salesforce to achieve this. We wanted to create an easy action for the sales execs to use from the opportunity and quickly perform all those actions at once.
The “Select Primary Quote” Lightning Flow
From a sales exec point of view, this is what the flow looks like:
1. The sales exec clicks on an action called “Select Primary Quote” from the opportunity.
2. The sales exec views a list of all the quotes under the opportunity and they can select which one should be the primary quote.
3. Then the sales exec has the option to update the opportunity status, the primary quote status and to reject the other quotes.
4. And that’s it! We just saved the sales executive a lot of clicks using flows.
For those of you who built flows before, you probably know it’s not possible to display a list of data in a table inside a flow. We’ve enhanced the out-of-the-box functionality of flows using a Lightning Component which we can reuse in the future.
Let’s investigate the flow and see how we embed this Lightning Component into a flow in Salesforce:
We won’t go into the detail of every single element in this flow. Overall this flow starts by setting some flow variables for our table component, we then move to the screen with our table selection.
After this, the flow gets the status of the ‘selected quote’ and allows the user to enter the additional options. We then update the opportunity, the quote and whether the user selected to reject the other quotes.
If you want to know more about this flow, both the flow and the Lightning component of the selection table are available by installing this Salesforce package in your Salesforce environment:
https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2p000000wdIZ
Ideally, we could get all the quotes in the flow using the “Get Records” flow element and pass them on to the Lightning component as an input. However, it’s not possible to pass a list parameter to Lightning components. Therefore, we’re going to pass enough information to the component and the component will know to get those quotes itself.
Let’s have a deeper look into the first flow screen which shows the selection table and embeds the Lightning component into our flow:
These are the inputs this component accepts:
- “Allow Selection” – this is a true or false input. This will tell the component if we’re only displaying records in a table or we’re also letting the user select a record. In our use case, we’ve passed “True”.
- “Object API Name” – The name of the object we want to show. We need here the Salesforce API name so that our component can get the records of the right object.
- “Field Set Name” – The columns the table is going to display for every record will be configured as a fieldset for the same object. This way an admin can quickly update the fields displayed without even updating the flow
- “No Records Message” – the message which will be displayed if there are no records. In this use case what will be displayed if there are no quotes for the opportunity.
- “Where Clause” – this is the filter criteria our component needs to show only the right records. Here we want to display only quotes related to the opportunity, so we set the “Where Clause” to be “OpportunityId = ‘*The Opportunity Id*’”
The output:
- “Selected Record Id” - We also set one output, we pass a flow variable and set it to the outputs section which will be set with the Id of the selected quote.
The rest of the flow is standard, we then use the “Select Quote Id” flow variable to get the status of that quote. The rest of the actions in the flow are to update the opportunity with the primary quote and new status, the primary quote with the new status and based on the user selection, reject the other quotes.
The Lightning Component
In this part, we’re going to dive into the code of the Lightning component. You don’t have to understand the code in order to use the component yourself in flows. However, if you wish to build a similar component, enhance this one or if you’re curious how the component works, this section is for you.
The code of the component is available in our Github:
https://github.com/Pexlify/Selection-Table-Aura-Component-for-Salesforce-Flows
First, when creating Lightning component for flows, we need to use the right interface “lightning:availableForFlowScreens” in our FlowTableSelection.cmp file:
<aura:component controller="FlowTableSelectionController" access="global" implements="lightning:availableForFlowScreens">
In order to set input and output variables from the flow, we’ll use the “design” file. This is our FlowTableSelection.design:
<design:component> <design:attribute name="ObjectAPIName" label="Object API Name" /> <design:attribute name="WhereClause" label="Where Clause" /> <design:attribute name="FieldSetName" label="Field Set Name" /> <design:attribute name="SelectedRecordId" label="Selected Record Id" /> <design:attribute name="AllowSelection" label="Allow Selection" /> <design:attribute name="NoRecordsMessage" label="No Records Message" default="There are no records to display"/> </design:component>
We’ll use those inputs in Apex when we query the right records, we’ll use the fieldset to get the fields to query and to display.
In our Aura component we use 2 additional component attributes:
- “FieldLabels” – List of the labels for every field we need to display. We’ll use it to populate the table headers.
- “TableRows” – List of records to display, every record will be a row in the table. Every row will have the list of fields (or columns) we need to display and the type of the field.
Our component will display every field in the right format depends on the field type in Salesforce. This could be improved to show lookup or master details fields with the name of the related record rather than the Salesforce Id.
This is what our FlowTableSelection.cmp looks like:
<aura:component controller="FlowTableSelectionController" access="global" implements="lightning:availableForFlowScreens"> <aura:attribute name="FieldLabels" type="List"/> <aura:attribute name="TableRows" type="List"/> <aura:attribute name="ObjectAPIName" type="String"/> <aura:attribute name="WhereClause" type="String"/> <aura:attribute name="FieldSetName" type="String"/> <aura:attribute name="SelectedRecordId" type="String"/> <aura:attribute name="AllowSelection" type="Boolean"/> <aura:attribute name="NoRecords" type="Boolean" default="true"/> <aura:attribute name="NoRecordsMessage" type="String"/> <aura:handler name="init" value="{!this}" action="{!c.doInit}"/> <aura:renderIf isTrue="{!not(v.NoRecords)}"> <table class="slds-table slds-table_bordered slds-table_striped slds-table_fixed-layout" style="border: 1px solid rgb(217, 219, 221);"> <thead> <tr class="slds-text-title_caps"> <aura:iteration items="{!v.FieldLabels}" var="field_label" > <th> <div class="slds-truncate">{!field_label}</div> </th> </aura:iteration> </tr> </thead> <tbody> <aura:iteration items="{!v.TableRows}" var="row"> <tr> <aura:iteration items="{!row.Fields}" var="field"> <td class="slds-truncate"> <lightning:layout > <aura:renderIf isTrue="{!and(v.AllowSelection, field.FirstField)}"> <lightning:layoutItem class="slds-m-right_x-small"> <lightning:input type="radio" label="" name="SelectedRecord" value="{!row.RecordId}" variant="label-hidden" onchange="{!c.handleRadioClick}"/> </lightning:layoutItem> </aura:renderIf> <lightning:layoutItem class="slds-truncate"> <aura:renderIf isTrue="{!(field.FieldType == 'BOOLEAN')}"> <lightning:input type="checkbox" disabled="true" label="" value="{!field.Value}" variant="label-hidden"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'CURRENCY')}"> <lightning:formattedNumber value="{!field.Value}" style="currency" maximumFractionDigits="2"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'PERCENT')}"> <lightning:formattedNumber value="{!field.Value}" style="percent" maximumFractionDigits="2"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'DOUBLE' || field.FieldType == 'LONG' || field.FieldType == 'INTEGER')}"> <lightning:formattedNumber value="{!field.Value}" style="decimal" maximumFractionDigits="2"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'PHONE')}"> <lightning:formattedPhone value="{!field.Value}"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'DATE' || field.FieldType == 'TIME' || field.FieldType == 'DATETIME' || field.FieldType == 'STRING' || field.FieldType == 'PICKLIST' || field.FieldType == 'MULTIPICKLIST' || field.FieldType == 'ADDRESS' || field.FieldType == 'ID' || field.FieldType == 'REFERENCE' || field.FieldType == 'EMAIL')}"> {!field.Value} </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'TEXTAREA')}"> <aura:unescapedHtml value="{!field.Value}"/> </aura:renderIf> <aura:renderIf isTrue="{!(field.FieldType == 'URL')}"> <lightning:formattedUrl value="{!field.Value}" /> </aura:renderIf> </lightning:layoutItem> </lightning:layout> </td> </aura:iteration> </tr> </aura:iteration> </tbody> </table> <aura:set attribute="else"> <div class="slds-align_absolute-center"> {!v.NoRecordsMessage} </div> </aura:set> </aura:renderIf> </aura:component>
To complete our component, let’s also look at the component Javascript controller and helper. We’re not doing too much there, when the component loads, we pass all the inputs to our Apex method to retrieve the field headers and the records. We’re also setting the “SelectedRecordId” variable when a row is selected.
FlowTableSelectionController.js:
({ doInit : function (component, event, helper) { helper.doInit(component, event, helper); }, handleRadioClick : function (component, event, helper) { let selected_record_id = event.getSource().get('v.value'); component.set('v.SelectedRecordId', selected_record_id); } })
FlowTableSelectionHelper.js
({ doInit : function (component, event, helper) { let action = component.get('c.getRecordsToDisplayInTable'); action.setParams({ sobject_name: component.get('v.ObjectAPIName'), field_set_name: component.get('v.FieldSetName'), where_clause: component.get('v.WhereClause') }); action.setCallback(this, function(response){ let state = response.getState(); if(state === 'SUCCESS'){ let apex_method_result = response.getReturnValue(); if(apex_method_result.Success){ if(apex_method_result.TableRows.length != 0){ component.set("v.NoRecords", false); component.set("v.FieldLabels", apex_method_result.FieldLabels); component.set("v.TableRows", apex_method_result.TableRows); } } else { helper.handleError(component, apex_method_result.ErrorMessage); } } else if(state === 'ERROR'){ helper.handleError(component, response.getError()); } }); $A.enqueueAction(action); }, handleError : function (component,error) { component.set("v.NoRecordsMessage", error); } })
To complete our component, let’s also look at our Apex controller. Here we first get the field set with all the fields we need to display.
private static Schema.FieldSet getFieldSetForObject(String sobject_name, String field_set_name){ Map<String, Schema.SObjectType> global_describe = Schema.getGlobalDescribe(); if(!global_describe.containsKey(sobject_name)){ throw new FlowTableSelectionException('Bad object specified ' + sobject_name); } Schema.SObjectType sobject_type = global_describe.get(sobject_name); Schema.DescribeSObjectResult sobject_type_describe = sobject_type.getDescribe(); if(!sobject_type_describe.FieldSets.getMap().containsKey(field_set_name)){ throw new FlowTableSelectionException('Can\'t find fieldset ' + field_set_name); } return sobject_type_describe.FieldSets.getMap().get(field_set_name); }
We create a list of labels for those fields for our table header.
public void setFieldLabels(Schema.FieldSet field_set){ for(Schema.FieldSetMember fieldset_member : field_set.getFields()){ this.FieldLabels.add(fieldset_member.getLabel()); } }
We then generate our SOQL query using dynamic SOQL.
private static String getQueryForOjbectFieldSetAndWhereClause(String sobject_name, Schema.FieldSet field_set, String where_clause){ List<String> fields_api_name = new List<String>(); for(Schema.FieldSetMember fieldset_member : field_set.getFields()){ fields_api_name.add(fieldset_member.getFieldPath()); } String query = 'SELECT ' + String.join(fields_api_name, ', ') + ' FROM ' + sobject_name; if(!String.isBlank(where_clause)){ query += ' WHERE ' + where_clause; } return query; }
Using the SOQL query we get all the records and then we organize them for our Aura component so that it’s easy to display every row with the right fields in the correct display types.
@AuraEnabled public static ApexMethodResult getRecordsToDisplayInTable(String sobject_name, String field_set_name, String where_clause){ ApexMethodResult apex_method_result = new ApexMethodResult(); try { Schema.FieldSet field_set = getFieldSetForObject(sobject_name, field_set_name); apex_method_result.setFieldLabels(field_set); String query = getQueryForOjbectFieldSetAndWhereClause(sobject_name, field_set, where_clause); List<sObject> table_records = Database.query(query); for(sObject table_record : table_records){ TableRow table_row = new TableRow(table_record, field_set); apex_method_result.TableRows.add(table_row); } } catch(Exception e){ apex_method_result.handleException(e); } return apex_method_result; }
The full controller looks like this:
public class FlowTableSelectionController { @AuraEnabled public static ApexMethodResult getRecordsToDisplayInTable(String sobject_name, String field_set_name, String where_clause){ ApexMethodResult apex_method_result = new ApexMethodResult(); try { Schema.FieldSet field_set = getFieldSetForObject(sobject_name, field_set_name); apex_method_result.setFieldLabels(field_set); String query = getQueryForOjbectFieldSetAndWhereClause(sobject_name, field_set, where_clause); List<sObject> table_records = Database.query(query); for(sObject table_record : table_records){ TableRow table_row = new TableRow(table_record, field_set); apex_method_result.TableRows.add(table_row); } } catch(Exception e){ apex_method_result.handleException(e); } return apex_method_result; } private static Schema.FieldSet getFieldSetForObject(String sobject_name, String field_set_name){ Map<String, Schema.SObjectType> global_describe = Schema.getGlobalDescribe(); if(!global_describe.containsKey(sobject_name)){ throw new FlowTableSelectionException('Bad object specified ' + sobject_name); } Schema.SObjectType sobject_type = global_describe.get(sobject_name); Schema.DescribeSObjectResult sobject_type_describe = sobject_type.getDescribe(); if(!sobject_type_describe.FieldSets.getMap().containsKey(field_set_name)){ throw new FlowTableSelectionException('Can\'t find fieldset ' + field_set_name); } return sobject_type_describe.FieldSets.getMap().get(field_set_name); } private static String getQueryForOjbectFieldSetAndWhereClause(String sobject_name, Schema.FieldSet field_set, String where_clause){ List<String> fields_api_name = new List<String>(); for(Schema.FieldSetMember fieldset_member : field_set.getFields()){ fields_api_name.add(fieldset_member.getFieldPath()); } String query = 'SELECT ' + String.join(fields_api_name, ', ') + ' FROM ' + sobject_name; if(!String.isBlank(where_clause)){ query += ' WHERE ' + where_clause; } return query; } @TestVisible private class ApexMethodResult { @AuraEnabled public List<TableRow> TableRows; @AuraEnabled public List<String> FieldLabels; @AuraEnabled public Boolean Success; @AuraEnabled public String ErrorMessage; public ApexMethodResult(){ this.Success = true; this.TableRows = new List<TableRow>(); this.FieldLabels = new List<String>(); } public void handleException(Exception e){ this.Success = false; this.ErrorMessage = e.getMessage(); } public void setFieldLabels(Schema.FieldSet field_set){ for(Schema.FieldSetMember fieldset_member : field_set.getFields()){ this.FieldLabels.add(fieldset_member.getLabel()); } } } @TestVisible private class TableRow { @AuraEnabled public List<Field> Fields; @AuraEnabled public String RecordId; public TableRow(sObject record, Schema.FieldSet field_set){ this.RecordId = record.Id; this.Fields = new List<Field>(); for(Schema.FieldSetMember fieldset_member : field_set.getFields()){ Field table_row_field = new Field(record, fieldset_member, this.Fields.isEmpty()); this.Fields.add(table_row_field); } } } @TestVisible private class Field { @AuraEnabled public String Value; @AuraEnabled public String FieldType; @AuraEnabled public Boolean FirstField; public Field(sObject record, Schema.FieldSetMember fieldset_member, Boolean first_field){ this.FirstField = first_field; String field_api_name = fieldset_member.getFieldPath(); this.FieldType = String.valueOf(fieldset_member.getType()); if(record.get(field_api_name) != null){ if(this.FieldType == 'DATE'){ this.Value = ((Date)record.get(field_api_name)).format(); } else if (this.FieldType == 'DATETIME'){ this.Value = ((DateTime)record.get(field_api_name)).format(); } else if (this.FieldType == 'PERCENT'){ this.Value = String.valueOf((Decimal)record.get(field_api_name) / 100.0); } else { this.Value = String.valueOf(record.get(field_api_name)); } } } } @TestVisible private class FlowTableSelectionException extends Exception {} }