[SalesForce] Dynamically display Field Set based on a field

I have created a VF page that calls an Opp field set. What the page does is it enables users to pick a Stage to indicate that the deal has been lost/rejected and supply the appropriate reason. The page houses all the required fields regardless of the Stage the user picks and is being called thru a custom button. It works fine until I got another requirement that asks to have different field sets displayed based on the Stage value. As I figured out this can't be possible because they have to pick the Stage in the page itself, I considered using another field as an indicator of what field set to display. Now it's just a simple checkbox. The problem is I've read a couple of related posts but just can't figure out/decide which is the best option and the end goal is not to make it a really complicated code. Here's one that I think is the closest to what I'm trying to achieve: Dynamically display fields based on Picklist for create page. Can someone please look at the code below and let me know what I need to add (actionSupport and actionRegion?):

<apex:page standardController="Opportunity" sidebar="true" showHeader="true" showChat="false" tabStyle="Opportunity" id="mainPage">
<apex:pagemessages ></apex:pagemessages>
    <apex:form id="mainForm">
    <apex:pageBlock title="Required Fields for Closed Lost" id="mainBlock">
        <apex:pageBlockSection columns="2" id="mainSection">
            <apex:inputField value="{!Opportunity.StageName}" rendered="true" required="true" id="stageName" onchange="checkStage()"/>
            <apex:repeat value="{!$ObjectType.Opportunity.FieldSets.Closed_Lost_Required_Fields}" var="field">
                <apex:inputField value="{!Opportunity[field]}" required="false"/>
            </apex:repeat>
            <apex:inputField label="" id="cWRF" style="display:none;" value="{!Opportunity.Closed_Lost_Required_Fields__c}"/>
        </apex:pageBlockSection>
        <apex:pageBlockButtons >
            <apex:commandButton value="Save" action="{!save}"/>
            <apex:commandButton value="Cancel" action="{!cancel}"/>
        </apex:pageBlockButtons>
    </apex:pageBlock>
    </apex:form>

</apex:page>

Dont mind the cWRF checkbox; it's just in place to act as validation to ensure all required fields for a given Stage value is filled out. Thanks!

Best Answer

This is probably impossible without using a controller extension (nor a custom controller). I was able to accomplish this with a relatively simple Visualforce page and controller extension.

For the examples I'll be giving, I'm working with the OpportunityLineItem object. This is because my org currently has something like 10 different fieldsets on this object, making it easy to fiddle with building an example page/controller extension. The fieldsets I'll be referencing are

  • Enhanced_Services_Enhanced_Services_Shar
  • Network_Internet_Internet_Bandwidth
  • Enhanced_Services_Internet_QoS
  • Managed_Services_Collocation_Power
  • Network_Ethernet_EVC

I was able to get somewhat close to accomplishing this with no code outside of the Visualforce page by using the $objectType global variable with a dynamic reference like this:

<apex:repeat value="{!$ObjectType.OpportunityLineItem.Fieldsets['Network_Ethernet_EVC']}" var="f2">
   <apex:inputField value="{!OpportunityLineItem[f2]}" />
</apex:repeat>

I ended up running into two issues with this approach.

  • I couldn't find a way to dynamically generate the name of the fieldset
  • If we could do that, the dynamic call {!$ObjectType.OpportunityLineItem.Fieldsets['Network_Ethernet_EVC']} is treated differently than the equivalent static call {!$ObjectType.OpportunityLineItem.Fieldsets.Network_Ethernet_EVC} is treated.
    • This results in an SObject row was retrieved via SOQL without querying the requested field error

Now, on to the example using a controller extension that I got to work

Controller Extension:

public class OLIControllerExtensionTest{
    private ApexPages.StandardController con;
    public OpportunityLineItem oli {public get; private set;}

    // This map explicitly maps values of some arbitrary field to names of fieldsets
    private static Map<String, String> prodTypeToFieldset = new Map<String, String>{
      '1' => 'Enhanced_Services_Enhanced_Services_Shar',
      '2' => 'Network_Internet_Internet_Bandwidth',
      '3' => 'Enhanced_Services_Internet_QoS',
      '4' => 'Managed_Services_Collocation_Power',
      '5' => 'Network_Ethernet_EVC'
    };

    // Just grab all the fieldsets in one go.
    private static Map<String, Schema.Fieldset> fieldsets = Schema.SObjectType.OpportunityLineItem.fieldSets.getMap();

    public OLIControllerExtensionTest(ApexPages.StandardController controller){
        this.con = controller;

        // This is a fairly simple way of querying EVERY field on an sObject.
        // We only need the fields used in the fieldsets, but that would take
        // more lines of code.
        String query = 'SELECT ' + String.join(new List<String>(Schema.SObjectType.OpportunityLineItem.fields.getMap().keySet()), ', ') + ' FROM OpportunityLineItem WHERE Id = \'' + controller.getId() + '\'';
        List<OpportunityLineItem> oliList = (List<OpportunityLineItem>)database.query(query);
        this.oli = oliList[0];
    }

    public List<Schema.FieldsetMember> getFieldset(){
        // Use the 'prototype' object from the controller.
        // If we don't, we'd be stuck with the initial value of prodType__c, and calling this method again (which happens during the re-render)
        // would never return anything different (than the fieldset that was returned when the page was first rendered)
        OpportunityLineItem tempOLI = (OpportunityLineItem)(this.con.getRecord());
        if(tempOLI == null || tempOLI.prodType__c == null){
            return OLIControllerExtensionTest.fieldsets.get('Network_Internet_IP_Addresses').getFields();
        }
        return OLIControllerExtensionTest.fieldsets.get(OLIControllerExtensionTest.prodTypeToFieldset.get(tempOLI.prodType__c)).getFields();
    }

    public String getCurrentValue(){
        return String.valueOf(this.con.getRecord().get('prodType__c'));
    }
}

Visualforce Page:

<apex:page standardController="OpportunityLineItem" showHeader="true" standardstylesheets="true" extensions="OLIControllerExtensionTest" title="Opportunity Product: {!opportunityLineItem.PricebookEntry.Product2.Name}">
    <apex:form>
        <apex:pageBlock>
            <apex:pageBlockSection>
                <apex:inputField value="{!OpportunityLineItem.prodType__c}">
                    <apex:actionSupport event="onchange" rerender="dynamicFields"/>
                </apex:inputField>
            </apex:pageBlockSection>
            <apex:pageBlockSection id="dynamicFields">
                <apex:inputField value="{!oli['Id']}" />
                <apex:repeat value="{!Fieldset}" var="f">
                    <apex:inputField value="{!oli[f]}" />
                </apex:repeat>
                <apex:outputText label="Current Value" value="{!currentValue}" />
            </apex:pageBlockSection>
        </apex:pageBlock>
    </apex:form>
</apex:page>

The important things in the page and the controller extension that make everything work are the following:

  1. The <apex:actionSupport>, placed in-between <apex:inputField></apex:inputField>.

    • This allows us to re-render a block, as <apex:inputField> doesn't have a rerender attribute on its own, and a partial re-rendering of the page is required to have a change to a value on the page affect the layout of the page. Re-rendering the 'dynamicFields'block will end up calling the controller extension's getFieldset() method again.
  2. In the controller extension's getFieldset() method, using the object instance returned by the getRecord() method of the standard controller.

    • The apex StandardController (and StandardSetController as well) maintain a 'prototype object' in memory, which is accessible through the getRecord() method of the controller.

    • For input fields on a Visualforce page, changes to those fields are stored in the prototype object until the save() method is called. When Save() is called, Salesforce uses the prototype object to update the record(s) initially passed to the controller.

    • Before the record is saved, the only way to access the values the user has input is through that prototype object. You can (and I have, in the past) maintain your own 'prototype' object in a controller/extension to achieve the same thing. Doing this would be helpful if you wanted to apply this to a Visualforce page that could create a new record (instead of only updating an existing record) but, for this question, this would just add unnecessary code.

  3. Using {!oli[f]} for the fields being dynamically displayed based on a fieldset

    • The standard controller only pulls in fields that can be determined statically at compilation. When dynamically choosing fieldsets (like we're doing here), you need to explicitly query all the fields that you can possibly reference. The controller extension does this, and stores the result in the oli variable. If we didn't do this, you'd run into the SObject row was retrieved via SOQL without querying the requested field error.

    • This could be worked around by using the standard controller's addFields() method, but addFields() causes serious issues when writing unit tests (so I avoid using it)

    • If you are using a static reference to a fieldset in an <apex:repeat> like <apex:repeat value="{!$ObjectType.OpportunityLineItem.Fieldsets.Network_Ethernet_EVC}" var="f">, Salesforce can statically determine what those fields are. In this case, using <apex:inputField value="{!OpportunityLineItem[f]}"/> would work just fine.

  4. Explicitly making the map from field values to fieldset names

    • This gives the solution flexibility. Set the keys to the StageName values instead, and you can use that field to dynamically choose a fieldset
    • The weakness here though is that the map is hard-coded, any changes would require a deployment. There's probably a way to use a Custom Setting or Custom Metadata type to store this information, but I don't have the time to investigate that
Related Topic