Apex Detail With Mode Edit

Introduction

There are lots of reason why you want to reproduce to 100% the edit or creation mode of Salesforce. Just go here and you will see that this idea has already a few thousand points on it! There are of course workarounds to reproduce this interface, like building the interface manually, creating change set... but this is highly inflexible. As soon as your administrator wants to change the layout, a developer has to jump in, the layout looks anyway really different. And what about RecordTypes!? Well there is a solution and I will explain it. Source code can be found at the end of the article.

SOAP API

As you probably notice, there are unfortutanely no way to describe a layout in APEX, but the SOAP API is much richer and allow it. It's basically now just about building the interface and for that, dynamic apex will be our tool.

Our page will be quite simple in order to reuse the code in different objects of the org:

<apex:page showHeader="true" sidebar="true" standardController="Account" extensions="CreateEditExtensionController" tabStyle="Account" id="page">
    <apex:pagemessages id="pageMessage"/>
    <c:CreateEdit />

    <apex:dynamicComponent componentValue="{!Form}"/>
</apex:page>

Not much here to say: we have our dynamic component, we have an apex:pagemessages to display error when it happens and finally with an apex component.

As I said, we will need the SOAP API so I won't tell much how to parse a wsdl into apex. If you are interested in the generation and still don't know how to make it, you should have a look at wsdl2apex. In any case, you will find the source at the end. The generated SOAP class is quite skinny and it's because I removed all the methods I didn't need.

Controller

First, we need to connect to the SOAP API. Here is a small method to handle the authentication:

public static sobjectPartnerSoapSforceCom.Soap handlerMetadataInit() {
    //here the authentication to the soap partner.
    //we build something quite dynamic which does not depend on any url (like cs17.salesforce.com)
    //therefore, should work for production like for sandbox, however the remote site settings
    //needs to be updated in Salesforce in Order to allow this outbound communication.

    sobjectPartnerSoapSforceCom.Soap handler = new sobjectPartnerSoapSforceCom.Soap();

    handler.endpoint_x = 'https://' + System.URL.getSalesforceBaseURL().getHost().split('\\.')[1]  + '-api.salesforce.com/services/Soap/u/27.0/' + UserInfo.getOrganizationId();

    sobjectPartnerSoapSforceCom.SessionHeader_element sessionHeader = new sobjectPartnerSoapSforceCom.SessionHeader_element();
    sessionHeader.sessionId = UserInfo.getSessionId();
    handler.SessionHeader = sessionHeader;
    return handler; 
}

I set basically just an handler which will be used later to describe the layout. The salesforce URL is built dynamically which is handy when you are between different environment like sandbox/production.

The code for the dynamic apex component is much longer but it's quite straight forward to understand.

public Component.Apex.Form getForm() {
    // we query the layout and rebuild it as it is in the standard interface. There shouldn't be any
    // difference between the standard layout and this layout.
    //The only method to achieve that is by using the Soap Resources.
    //For that we make a request on the Soap Partner endpoint.

    if(Form==null){
        Form = new Component.Apex.Form();
        Form.id = 'form';
        Set rerenderFields = new Set();
        Set controllerFields = new Set();
        Component.Apex.sectionHeader sectionHeader = new Component.Apex.sectionHeader();
        sectionHeader.subtitle = subtitle;
        sectionHeader.title = title;
        Form.childComponents.add(sectionHeader);

        Component.Apex.pageBlock pageBlock = new Component.Apex.pageBlock();
        pageBlock.id = 'pageBlock';
        pageBlock.mode = 'edit';
        pageBlock.title = title;

        Component.Apex.pageBlockSection pBS;
        Component.Apex.InputField inputField;
        Component.Apex.InputText inputText;
        Component.Apex.InputHidden inputHidden;
        Component.Apex.Selectlist selectlist;
        Component.Apex.SelectOptions selectOptions;

        sobjectPartnerSoapSforceCom.Soap handler = HandlerMetadataInit();

        if(!test.isRunningTest()) {
            //if running test, we can't make a callout, so this won't work.
            //sObjectRecordType is the recordtype of the sobject. If the sobject has no recordtype, then it's null and it's still fine.
            sobjectPartnerSoapSforceCom.DescribeLayoutResult LayoutResult = handler.describeLayout(sObjectType, new list{sObjectRecordType});

            if(LayoutResult != null) {
                //we take the first one because we queried only one recordtype.               
                for(sobjectPartnerSoapSforceCom.DescribeLayoutSection LayoutSection:LayoutResult.Layouts.get(0).editLayoutSections) {
                    //the different sections.
                    pBS = new Component.Apex.pageBlockSection(
                        title=LayoutSection.heading,
                        columns = LayoutSection.columns
                    );

                    for(sobjectPartnerSoapSforceCom.DescribeLayoutRow layoutRow:LayoutSection.layoutRows) {
                        for(sobjectPartnerSoapSforceCom.DescribeLayoutItem layoutItem:layoutRow.layoutItems) {
                           if(layoutItem.layoutComponents!=null){
                                //here the fields.
                                for(sobjectPartnerSoapSforceCom.DescribeLayoutComponent layoutComponent:layoutItem.layoutComponents){
                                    if(layoutComponent.value=='OwnerId' || layoutComponent.value=='RecordTypeId' ){
                                        //The user shouldn't change the OwnerId/RecordType this way.
                                    }
                                    else if(sObjectType=='account' && (layoutComponent.value=='LastName' || layoutComponent.value=='FirstName' )) {
                                        inputText = new Component.Apex.InputText(
                                            id = layoutComponent.value
                                        );
                                        inputText.expressions.value = '{!'+sObjectType+'.' + layoutComponent.value + '}';

                                        pBS.childComponents.add(inputText);
                                        allFields.add(layoutComponent.value);
                                        rerenderFields.add(layoutComponent.value);
                                    }
                                    else if(sObjectType=='account' && layoutComponent.value=='Salutation'){
                                        selectlist = new Component.Apex.Selectlist(
                                            size = 1,
                                            multiselect = false
                                        );
                                        selectlist.expressions.value = '{!'+sObjectType+'.' + layoutComponent.value + '}';

                                        selectOptions = new Component.Apex.SelectOptions();
                                        selectOptions.expressions.value = '{!Salutation}';
                                        selectlist.childComponents.add(selectOptions);
                                        rerenderFields.add(layoutComponent.value);
                                        pBS.childComponents.add(selectlist);
                                    }
                                    else if(layoutComponent.value!=null ){
                                        inputField = new Component.Apex.InputField(
                                            required = layoutItem.required,
                                            id = layoutComponent.value
                                        );
                                        inputField.expressions.value = '{!'+sObjectType+'.' + layoutComponent.value + '}';
                                        pBS.childComponents.add(inputField);
                                        allFields.add(layoutComponent.value);

                                        //we store the controller fields, because we shouldn't rerender them - causing a bug...
                                        //rerendering a controlled fields makes the value disappear
                                        if(schemaFields.get(layoutComponent.value).getDescribe().isDependentPicklist()){
                                            controllerFields.add(schemaFields.get(layoutComponent.value).getDescribe().getController().getDescribe().getName() );
                                        }
                                    }
                                }
                            }
                            else {
                                //small workaround for adding empty fields when the layout require it
                                //in order to reproduce to 100% the standard layout.
                                pBS.childComponents.add(new Component.Apex.OutputText());
                            }

                        }
                    }
                    pageBlock.childComponents.add(pBS);
                }
            }
        }
        Component.Apex.pageBlockButtons pageBlockButtons = new Component.Apex.pageBlockButtons();

        Component.Apex.commandButton commandButton = new Component.Apex.commandButton();
        commandButton.expressions.action = '{!save}';
        commandButton.id='saveBtn';
        commandButton.expressions.value = '{!$label.site.save}';
        commandButton.onclick = 'fnSaveValuesAndCheck();';
        commandButton.oncomplete = 'fnLoadInputValues();';
        rerenderFields.add('pageMessage');

        //we remove the controller fields from the rerenderFields
        rerenderFields.removeAll(controllerFields);

        commandButton.rerender = rerenderFields;
        pageBlockButtons.childComponents.add(commandButton);

        commandButton = new Component.Apex.commandButton();
        commandButton.expressions.action = '{!cancel}';
        commandButton.id='cancelBtn';
        commandButton.expressions.value = '{!$label.site.cancel}';
        pageBlockButtons.childComponents.add(commandButton);

        pageBlock.childComponents.add(pageBlockButtons);

        Form.childComponents.add(pageBlock);
    }

    return Form;
}

Basically, we have at the end of this method a page:block page composed of sections, like in the standard interface. Also, this method takes care of RecordTypes automatically, which means that in any case, the user will have the correct layout and won't notice anything beside that the URL looks different.

If you paid attention, I bind a javascript event (fnSaveValuesAndCheck()) when the user clicks on "save". Dynamic Apex components are either very limited or it's a bug. Either way, when the user wants to save the record, an error may occur (like required field missing, validation rule happening, ...) and all the fields of the layout are reset, which is not really user friendly... Therefore, I first "save" (onclick event) all the values of the page in a Javascript Variable and then reload the variables once the page has been refreshed (oncomplete event). Not ideal, but it does work, at least.

Another thing, I removed the dependent picklists of the rerender event. Reason is that dependent picklist are emptied when the page is rerender through AJAX, another dynamic apex bug... However, if something wrong happens, the message still appears in the "apex:pageMessages" component.

Finally, last thing is the initialisation of the page:

public CreateEditExtensionController(ApexPages.StandardController standardControllerRec) {
    Opportunity = new Opportunity(); 
    Account = new Account();

    allFields = new Set();

    //Small bug with the "addFields" method of Salesforce and this is
    //the only way I found to find from where object the request comes from.
    sObjectType = ApexPages.currentPage().getUrl().toLowerCase().split('/apex/ac')[1].split('\\?')[0];

    //we describe the object and put all fields in this variable.
    schemaFields = Schema.getGlobalDescribe().get(sObjectType).getDescribe().fields.getMap();
    Set FieldssObject = new Set(schemaFields.keySet());

    //we add all available fields so that we don't get an error.
    //standardControllerRec.addFields(new list(FieldssObject));
    //standardControllerRec.addFields(new list{'RecordTypeId'});
    String myId = standardControllerRec.getId();

    sObject mySObject;
    if(myId!=null){
        mySObject = database.query('select '+ String.join(new list(FieldssObject), ',') +' from ' + sObjectType + ' where Id=:myId');
    }

    sObjectRecordType = null;

    //in case we have the recordtype in the url.
    //happens when the user goes through the creation wizard (like for account)
    if(ApexPages.currentPage().getParameters().containsKey('RecordType')) {
        sObjectRecordType = ApexPages.currentPage().getParameters().get('RecordType');
    }
    else if(FieldssObject.contains('recordtypeid')){
        //if this wasn't successful, maybe we can get the recordtypeid for an existing record.
        sObjectRecordType = (String)mySObject.get('recordtypeid');
    }
    //last else: would be in case the record doesn't have any recordtype.

    //we have just one page for edition and creation. Therefore we need that the labels are a bit
    //dynamic.


    Title = Schema.getGlobalDescribe().get(sObjectType).getDescribe().getLabel() + ' edit';

    if(mySObject==null||mySObject.Id==null){
        //clicked on create
        Subtitle = 'New ' + Schema.getGlobalDescribe().get(sObjectType).getDescribe().getLabel();
    }
    else {
        //object edit
        Subtitle = (String)mySObject.get('name');
    }

    getForm();

    if(!Test.isRunningTest()){
        //internal server error, if the test goes here...
        standardControllerRec.addFields(new list(allFields));
    }

    //workaround for setting correctly the recordtype if the user clicks on the link
    //"change recordtype" in the read-only layout mode.
    //We don't need to make that on other objects which don't have a recordtype
    if(sObjectType=='Opportunity'){
        Opportunity = (Opportunity)standardControllerRec.getRecord();
    }
    else if(sObjectType=='Account'){
        Account = (Account)standardControllerRec.getRecord();
    }

    if(ApexPages.currentPage().getParameters().containsKey('RecordType')) {
        //We may need to write the recordtype if it's present as url parameter
        Opportunity.RecordTypeId = ApexPages.currentPage().getParameters().get('RecordType');
        Account.RecordTypeId = ApexPages.currentPage().getParameters().get('RecordType');
    }

}

I had to declare manually the objects which have a recordtype: in my case, it was Opportunity and Account. The reason is that when the user clicks on "change recordtype" in the read-only mode, the recordtype is not correctly transmitted and the page is saved incorrectly. Again not ideal, but this does work. Also you should remove these 2 lines when you will reuse the source code if one these objects don't use record types.

Results

Finally the result when creating a new account (account have record types):

On the next screen, we arrive in the correct Record Type and the user have to fill out fields. If the user tries to save the record, error is happening because I didn't fill out all required fields:

It goes without saying, but in order it works, you still need to overwrite the buttons:

To find the sources of the project, you can go here

Finally, you shouldn't forget to set the remote url in the remote site settings. If you don't do it, you will get an appropriate error message.

comments powered by Disqus