Friday 1 April 2011

Dynamic Visualforce Bindings and AddFields Method

One of the new features in Spring 11 is Dynamic Visualforce Bindings, which allows you to determine the fields to display at runtime rather than at compile time.

As part of this, there's a new method on the ApexPages.StandardController class - addFields - that allows you to add fields in to the record being managed by name.  Below is an example page and controller that use this:

Page:

<apex:page standardController="Account" extensions="AddFieldsController">
   <apex:outputField value="{!Account['Name']}" /><br/>
   <apex:outputField value="{!Account['BillingCity']}" /><br/>
</apex:page>

Controller:

public class AddFieldsController 
{
 public AddFieldsController(ApexPages.StandardController stdCtrl)
 {
  stdCtrl.addFields(new List<String>{'Name', 'BillingCity'});
  Account acc=(Account) stdCtrl.getRecord();
 }
}

This all works correctly and the name and City is dynamically retrieved and displayed on the page.

Introducing the following test into the equation:

public static testMethod void testController()
{
 Account acc=new Account(name='Name');
 insert acc;
 AddFieldsController controller=new AddFieldsController(new ApexPages.StandardController(acc));
 System.debug('#### account = ' + acc);
}

causes an error to be reported - |System.SObjectException: You cannot call addFields after you've already loaded the data. This must be the first thing in your constructor

This is unexpected, firstly as it works fine when run live, and secondly as the addFields is the first thing in my constructor. So it looks like this is either a bug or something missing from the documentation.

Luckily this is easily solved - first I change the constructor so that it doesn't execute the addFields method if I am running a test:

public AddFieldsController(ApexPages.StandardController stdCtrl)
{
 if (!Test.isRunningTest())
 {
  stdCtrl.addFields(new List<String>{'Name', 'BillingCity'});
 }
 Account acc=(Account) stdCtrl.getRecord();
}


And secondly I change my test so that it populates every field the controller is expecting to add in:

public static testMethod void testController()
{
 Account acc=new Account(name='Name', BillingCity='Test');
 insert acc;
 AddFieldsController controller=new AddFieldsController(new ApexPages.StandardController(acc));
 System.debug('#### account = ' + acc);
}

18 comments:

  1. Awesome tip! This was just what I needed at 1:00 AM.

    My test method was further complicated by testing a web service callout, so I had to use the same conditional logic to avoid the callout and use an else statement to create a dummy response.

    ReplyDelete
  2. Interesting point. The message from addFields is wrong in this case, but the behaviour makes sense.

    addFields is used to control what fields get automatically loaded by the standard controller. But in your test method, you are providing the record the standard controller is to use, so addFields obviously has no ability to affect what fields are in it.

    The error message should say somethng about not being able to use addFields when you have passed a record to the controller....

    ReplyDelete
  3. While I kind of agree with this reasoning (and the Visualforce docs do say that this should be called before the record has been loaded), it doesn't quite stack up with the fact that you can call the reset() method on the controller and then the addFields() method. This indicates that addFields can be used to change the available fields at a later point than the record is constructed.

    I guess we'll have a better idea of how this works when Salesforce address this issue - see if they update the docs or change the behaviour of the method.

    ReplyDelete
  4. Both reset and addFields only work when the standard controller is responsible for loading the data.

    ReplyDelete
  5. Unfortunately Bob, this time your solution didn't work for me...

    I'm adding in child relationships and they aren't getting picked up...

    my controller adds:

    stdCtrlr.addFields(
    new list{
    'name',
    'RecordTypeName__c',
    'OppNumber__c',
    'AccountId',
    'CurrencyIsoCode',
    'Amount',
    'OwnerId',
    'IsClosed',
    'stageName',
    'Contracts__r',
    'OpportunityPartnersFrom',
    'OpportunityPartnersFrom.AccountToId',
    'OpportunityPartnersFrom.Role',
    'OpportunityPartnersFrom.isPrimary',
    'ChildOpportunities__r'
    }
    );

    And of course it works at runtime - but I can't get those fields into my test method...

    Just another reason why I think test methods are a waste of time and money... Make them consistent, Force!!!

    ReplyDelete
  6. Hi Jay,

    I think you should be able to add in the child information - are you using a relationship query to pull back all the details in your test method?

    ReplyDelete
  7. Bob, have you ever had any experience that a piece of code works in some instances of DE but not in others?

    I've been trying to run the example here at
    http://www.salesforce.com/us/developer/docs/pages/Content/pages_dynamic_vf_sample_standard.htm#pages_dynamic_ref_advanced_example

    But in one of my DE orgs, I kept getting "SObject row was retrieved via SOQL without querying the requested field" error. The error I got, I got in a DE that I created on 15 Dec 2012. However when I tried the code in a DE that I only created last week, it works. Then I tried it in a DE that I created a year ago, it also works. It's only the one DE I created a month ago that reported the error.

    I cannot for the life of me figure out why that is the case. Have you encountered something like that?

    ReplyDelete
    Replies
    1. The only time I've encountered this is when I've had different setups in each dev org - e.g. I've created various portals or disabled chatter. That can introduce additional fields and sobjects.

      Delete
    2. Wow that's pretty bad then. You would think the behavior should be consistent across orgs... Most of the dynamic VF examples in the Visualforce Developer's Guide don't work for my org at all.

      Delete
  8. Bob,

    Thank you for this, it's very helpful.

    Andy

    ReplyDelete
  9. Bob -- I came across this recently and found another workaround, which I think is better (because it doesn't create an untestable line of apex). Let me know what you think: http://raydehler.com/cloud/clod/best-practice-related-fields-with-standard-controller-extensions.html

    ReplyDelete
    Replies
    1. Hi Ray,

      That's a technique I've used quite a bit in the past when I know the fields that I want to be populated in the controller.

      While I agree it works fine for this simplistic example, with hardcoded field names in the page and hardcoded list of fields in the extension controller, its not that useful with dynamic Visualforce bindings, as the expression to define the field name can be a complex formula. If you have to hardcode all of the fields that expression can evaluate to in the page then you lose some of the value of the dynamic bindings.

      Delete
    2. It is the correct solution to the scenario that your blog addresses though - how to make additional fields available to the extension controller.

      Delete
    3. Thanks for the feedback, Bob.

      Delete
  10. Hi Bob,

    I've encountered the "System.SObjectException: You cannot call addFields after you've already loaded the data" error when I try the following scenario.

    I'm trying to use the same controller class for different objects & within this to addFields. However I need to determine which object type it is to determine which fields to add.

    I use the following in the constructor
    if (!Test.isRunningTest()) stdController.addFields(getAllFieldsList(stdController.getRecord().getSObjectType()));

    As I use getRecord() on the standardController "addFields" is not in effect the first time it's used. I tried to move this outside of the constructor though based on the following from SFDC "This method should be called before a record has been loaded—typically, it's called by the controller's constructor. If this method is called outside of the constructor, you must use the reset() method before calling addFields()."

    Therefore if I call a function from my constructor & pass the standardcontroller to the function I therefore then try the following.

    stdController.reset();
    stdController.addFields(getAllFieldsList(stdController.getRecord().getSObjectType().getDescribe().getName()));

    however the same error occurs. Am I being too ambitious by trying to use the same controller for different objects? If so I guess I can use separate one's however I hoped this would work :).

    if you've any thoughts on this and feedback would be welcomed.

    Thanks.

    ReplyDelete
  11. Hey Bob,

    Thanks for this. Did you ever find out if this is a bug or a as-designed behaviour? Thanks!

    ReplyDelete
  12. Hi Bob,

    I have to create a manage package contains one page which should be flexible so that I can add that it in any object(standard or Custom) page-layout as an Inline Page.
    As we know for use a page as Inline Page in the page-layout we should have to use standard controller of that object, so is there any way by which we can assign Standard controller attribute dynamically.

    Or is there any other way by which we can achieve this.

    Thanks in advance

    ReplyDelete
  13. Hi Bob,

    Can you please tell how to use reset() and addFields method outside of constructor. As per documentation we have to use reset() before addFields() if we need to add fields outside of constructor. For example, as per documentation we need to do following:

    controller.reset();
    controller.addFields('');

    It seems a little odd that you are first resetting and then adding fields. It would have made more sense if we first added fields using addFields method and then called reset() method to refetch the added fields. For example:

    controller.addFields('');
    controller.reset();

    Also the approach mentioned in documentation does not work. It throws the fillowing exception:
    SObject row was retrieved via SOQL without querying the requested field:

    I could not find any example of addFields and reset. Please post an example of the same for clarity.

    ReplyDelete