5

I have a Visualforce Page for QuoteLineItem multiple edit. It works as a shopping cart: the top section has the items already in the Quote, and in the bottom there are the available products from the selected Price Book. The user can add/remove items from the shopping cart, edit some custom fields and then Save everything and return to the Quote.

But I'm getting a weird behavior in a certain sequence of steps (red arrow is where I will click next).

  1. There are already items in the cart. I will add some random item.

  2. The new item is added, without any of the custom visible fields populated. I will now remove one of the existing items. enter image description here

  3. The recently added line has now only one of the custom fields populated with whatever was in the (n-1) position. See in the viewState that it has NOT that field populated in the object. enter image description here

  4. If I try to save it, or add a new item afterwards, that dropbox gets back to not being filled, as it should be.

Oh, and that field (New/Renewal) should not even be possible to populate before setting something in "Fee Type" field, it has a field dependency.

So, I have no idea what is going on. From everything I look in code, it seems OK. I have even written an apex test that replicates these steps and it works. I guess it is something UI related that I am messing up.

Visualforce page:

<apex:page standardController="Quote" extensions="QuoteLineItemEntryExtension" action="{!priceBookCheck}" cache="False">

    <c:LoadingBox />
    <apex:sectionHeader Title="Manage {!$ObjectType.Product2.LabelPlural}" subtitle="{!quote.Name}"/>
    <apex:messages style="color:red"/>

    <style>
        .search{
            font-size:14pt;
            margin-right: 20px;    
        }
        .fyi{
            color:red;
            font-style:italic;
        }
        .label{
            margin-right:10px;
            font-weight:bold;
        }
    </style>

    <script type='text/javascript'>

        // This script assists the search bar functionality
        // It will execute a search only after the user has stopped typing for more than 1 second
        // To raise the time between when the user stops typing and the search, edit the following variable:

        var waitTime = 0.4;


        var countDown = waitTime;
        var started = false;

        function resetTimer(){

            countDown=waitTime;

            if(started==false){
                started=true;
                runCountDown();
            }
        }

        function runCountDown(){

            countDown -= 0.2;

            if(countDown<=0){
                fetchResults();
                started=false;
            }
            else{
                window.setTimeout(runCountDown,200);
            }
        }
</script>

    <apex:form >

        <apex:outputPanel id="mainBody">

            <apex:outputLabel styleClass="label">PriceBook: </apex:outputLabel>
            <apex:outputText value="{!theBook.Name}"/>&nbsp;
            <apex:commandLink action="{!changePricebook}" value="change" immediate="true"/>
            <br/>
            <!-- not everyone is using multi-currency, so this section may or may not show -->
            <apex:outputPanel rendered="{!multipleCurrencies}">
                <apex:outputLabel styleClass="label">Currency: </apex:outputLabel>
                <apex:outputText value="{!chosenCurrency}"/>
                <br/>
            </apex:outputPanel>
            <br/>

<!-- this is the upper table... a.k.a. the "Shopping Cart"-->

            <!-- notice we use a lot of $ObjectType merge fields... I did that because if you have changed the labels of fields or objects it will reflect your own lingo -->
            <apex:pageBlock title="Selected {!$ObjectType.Product2.LabelPlural}" id="selected_items">

                <apex:variable var="index" value="{!0}"/>

                <apex:pageblockTable value="{!shoppingCart}" var="s">

                    <apex:column headerValue="Index" value="{!index}"/>

                    <apex:column>
                        <apex:commandButton value="Remove" action="{!removeFromShoppingCart}" reRender="selected_items,searchResults" immediate="true" status="loadStatus">
                            <!-- this param is how we send an argument to the controller, so it knows which row we clicked 'remove' on -->
                            <apex:param value="{!index}" assignTo="{!toUnselect}" name="toUnselect"/>
                        </apex:commandButton>

                        <!-- Increment our index counter -->
                        <apex:variable var="index" value="{!index +1}"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.Product2.LabelPlural}" value="{!s.PriceBookEntry.Product2.Name}"/>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Fee_Type__c.Label}">
                        <apex:inputField value="{!s.Fee_Type__c}" required="true"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Quantity.Label}">
                        <apex:inputField value="{!s.Quantity}" style="width:70px" required="true"/>
                    </apex:column>

                    <!--
                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.UnitPrice.Label}">
                        <apex:inputField value="{!s.UnitPrice}" style="width:70px" required="true"/>
                    </apex:column>
                     -->

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.License_category__c.Label}">
                        <apex:inputField value="{!s.License_category__c}" required="true"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.License_start_date__c.Label}">
                        <apex:inputField value="{!s.License_start_date__c}"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.License_end_date__c.Label}">
                        <apex:inputField value="{!s.License_end_date__c}" />
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Uplift__c.Label}">
                        <apex:inputField value="{!s.Uplift__c}" style="width:70px" />
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.SPF__c.Label}">
                        <apex:inputField value="{!s.SPF__c}" style="width:70px"  required="true"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Major_Account_Discount__c.Label}">
                        <apex:inputField value="{!s.Major_Account_Discount__c}" style="width:70px"  required="false"/>
                    </apex:column>


                    <!--  Services related fields -->

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Service_description__c.Label} (Services)">
                        <apex:inputField value="{!s.Service_description__c}" required="false"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Start_date__c.Label} (Services)">
                        <apex:inputField value="{!s.Start_date__c}" required="false"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Duration__c.Label} (Services)">
                        <apex:inputField value="{!s.Duration__c}" required="false"/>
                    </apex:column>

                </apex:pageblockTable>


                <apex:pageBlockButtons >
                    <apex:commandButton action="{!onSave}" value="Save" status="loadStatus"/>
                    <apex:commandButton action="{!onCancel}" value="Cancel" immediate="true" status="loadStatus"/>
                </apex:pageBlockButtons>

            </apex:pageBlock>

<!-- this is the lower table: search bar and search results -->

            <apex:pageBlock >

                <apex:outputPanel styleClass="search">
                    Search for {!$ObjectType.Product2.LabelPlural}:
                </apex:outputPanel>

                <apex:actionRegion renderRegionOnly="false" immediate="true">

                    <apex:actionFunction name="fetchResults" action="{!updateAvailableList}" reRender="searchResults" status="searchStatus"/>

                    <!-- here we invoke the scripting to get out fancy 'no button' search bar to work -->
                    <apex:inputText value="{!searchString}" onkeydown="if(event.keyCode==13){this.blur();}else{resetTimer();}" style="width:300px"/>
                    &nbsp;&nbsp;
                    <i>
                        <!-- actionStatus component makes it easy to let the user know when a search is underway -->
                        <apex:actionStatus id="searchStatus" startText="searching..." stopText=" "/>
                    </i>

                </apex:actionRegion>

                <br/>
                <br/>

                <apex:outputPanel id="searchResults">

                    <apex:pageBlockTable value="{!AvailableProducts}" var="a">

                        <apex:column headerValue="{!$ObjectType.Product2.Fields.Name.Label}" value="{!a.Product2.Name}" />

                        <apex:column headerValue="{!$ObjectType.Product2.Fields.Family.Label}" value="{!a.Product2.Family}"/>

                        <apex:column headerValue="{!$ObjectType.Product2.Fields.Legacy__c.Label}" value="{!a.Product2.Legacy__c}"/>

                        <apex:column headerValue="Sales Price" value="{!a.UnitPrice}"/>

                        <apex:column headerValue="{!$ObjectType.Product2.Fields.Description.Label}" value="{!a.Product2.Description}"/>

                        <apex:column >
                            <!-- command button in a column... neato -->
                            <apex:commandButton value="Select" action="{!addToShoppingCart}" reRender="selected_items,searchResults" immediate="true" status="loadStatus">
                                <!-- again we use apex:param to be able to tell the controller which row we are working with -->
                                <apex:param value="{!a.Id}" assignTo="{!toSelect}" name="toSelect"/>
                            </apex:commandButton>
                        </apex:column>

                    </apex:pageBlockTable>

                    <!-- We put up a warning if results exceed 100 rows -->
                    <apex:outputPanel styleClass="fyi" rendered="{!overLimit}">
                        <br/>
                        Your search returned over 100 results, use a more specific search string if you do not see the desired {!$ObjectType.Product2.Label}.
                        <br/>
                    </apex:outputPanel>

                </apex:outputPanel>

            </apex:pageBlock>

        </apex:outputPanel>

    </apex:form>

</apex:page>

Add and remove function excerpts, Apex code (controller):

public with sharing class QuoteLineItemEntryExtension {

    public Quote theQuote {get;set;}
    public String searchString {get;set;}
    public quoteLineItem[] shoppingCart {get;set;}
    public priceBookEntry[] AvailableProducts {get;set;}
    public Pricebook2 theBook {get;set;}   

    public String toSelect {get; set;}
    public String toUnselect {get; set;}
    public Decimal Total {get;set;}

    public Boolean overLimit {get;set;}
    public Boolean multipleCurrencies {get; set;}

    private Boolean forcePricebookSelection = false;

    private quoteLineItem[] forDeletion = new quoteLineItem[]{};

    private void Initialize(ApexPages.StandardController controller) {
        // Need to know if org has multiple currencies enabled
        multipleCurrencies = UserInfo.isMultiCurrencyOrganization();

        // Get information about the quote being worked on
        if (multipleCurrencies) {
            theQuote = database.query('select Id, Pricebook2Id, Pricebook2.Name, CurrencyIsoCode from Quote where Id = \'' + controller.getRecord().Id + '\' limit 1');
        }
        else {
            theQuote = [select Id, Pricebook2Id, PriceBook2.Name from Quote where Id = :controller.getRecord().Id limit 1];
        }

        // If products were previously selected need to put them in the "selected products" section to start with
        shoppingCart = [select Id, Quantity, TotalPrice, UnitPrice, Description, License_start_date__c, License_end_date__c, SPF__c,
            License_category__c, Fee_Type__c, Uplift__c, Major_Account_Discount__c, Service_description__c, Start_date__c, Duration__c,
            PriceBookEntryId, PriceBookEntry.Name, PriceBookEntry.IsActive, PriceBookEntry.Product2Id, PriceBookEntry.Product2.Name, 
            PriceBookEntry.PriceBook2Id from quoteLineItem where QuoteId=:theQuote.Id ORDER BY SortOrder ASC];

        // Check if Opp has a pricebook associated yet
        if(theQuote.Pricebook2Id == null){
            Pricebook2[] activepbs = [select Id, Name from Pricebook2 where isActive = true limit 2];
            if(activepbs.size() == 2){
                forcePricebookSelection = true;
                theBook = new Pricebook2();
            }
            else{
                theBook = activepbs[0];
            }
        }
        else{
            theBook = theQuote.Pricebook2;
        }

        if(!forcePricebookSelection) {
            updateAvailableList();
        }
    }


    public QuoteLineItemEntryExtension(ApexPages.StandardController controller)
    {
        Initialize(controller);
    }

    public PageReference addToShoppingCart() {
        for(PricebookEntry d : AvailableProducts){
            String entry_id = (String) d.Id;
            if (entry_id.equals(toSelect)) {
                QuoteLineItem new_item = new QuoteLineItem(
                    QuoteId=theQuote.Id, 
                    PriceBookEntry=d, 
                    PriceBookEntryId=d.Id, 
                    UnitPrice=d.UnitPrice, 
                    Quantity=1,
                    SPF__c=0
                );
                shoppingCart.add(new_item);
                break;
            }
        }
        return null;
    }


    public PageReference removeFromShoppingCart() {
        QuoteLineItem to_remove = shoppingCart.remove(Integer.valueOf(toUnselect));
        if (to_remove.Id != null) {
            forDeletion.add(to_remove);
        }
        return null;
    }


    public PageReference onSave()
    {
        // If previously selected products are now removed, we need to delete them
        try {
            if(forDeletion.size()>0) {
                delete(forDeletion);
            }
        } catch (System.DmlException e) {
            if (e.getMessage().contains('ENTITY_IS_DELETED')) {
                ApexPages.addMessage(
                    new ApexPages.Message(
                        ApexPages.Severity.WARNING,
                        'Tried to delete the same item more than once. Please check if the Quote now contains the desired items and repeat the delete operation if necessary.'
                    )
                );
                return null;
            }
            else {
                ApexPages.addMessages(e);
                return null;
            }
        }

        // Previously selected products may have new quantities and amounts, and we may have new products listed, so we use upsert here
        try{
            if(shoppingCart.size()>0) {
                upsert(shoppingCart);
            }
        }
        catch(Exception e){
            ApexPages.addMessages(e);
            return null;
        }  

        // After save return the user to the quote
        return new PageReference('/' + ApexPages.currentPage().getParameters().get('Id'));
    }


    public PageReference onCancel()
    {
        // If user hits cancel we commit no changes and return them to the quote   
        return new PageReference('/' + ApexPages.currentPage().getParameters().get('Id'));
    }


    public void updateAvailableList()
    {
        // We dynamically build a query string 
        // The original code excludes items already in the shopping cart, but we removed this feature 
        String qString = 'select Id, Pricebook2Id, IsActive, Product2.Name, Product2.Family, Product2.Legacy__c, Product2.IsActive, Product2.Description, UnitPrice from PricebookEntry where IsActive=true and Pricebook2Id = \'' + theBook.Id + '\'';
        if(multipleCurrencies) {
            qstring += ' and CurrencyIsoCode = \'' + theQuote.get('currencyIsoCode') + '\'';
        }

        // note that we are looking for the search string entered by the user in the name OR description
        // modify this to search other fields if desired
        if (searchString!=null) {
            qString+= ' and (Product2.Name like \'%' + searchString + '%\' or Product2.Description like \'%' + searchString + '%\')';
        }

        qString+= ' order by Product2.Name';
        qString+= ' limit 201';

        system.debug('qString:' +qString);
        AvailableProducts = database.query(qString);

        // We only display up to 200 results... if there are more than we let the user know (see vf page)
        if (AvailableProducts.size()==201) {
            AvailableProducts.remove(200);
            overLimit = true;
        }
        else{
            overLimit=false;
        }
    }   

}
  • 1
    Hi Mauricio! While this is a great question, I think we might need to see your page's code as well. It probably is some combination of required+rerender, but it's hard to be certain without seeing code. – sfdcfox Oct 31 '16 at 16:15
  • Hi there! I just added the whole visualforce page, but only part of the apex code to the question. – Mauricio Oliveira Oct 31 '16 at 16:36
  • I just tested removing the immediate="true" from my Remove button. With this change the user is not allowed to remove an existing an item before filling the new item first, and when this is followed the reported error does not occur. But this is not exactly the behavior I want, and now I am very curious to know what is going on. Anybody? – Mauricio Oliveira Nov 1 '16 at 17:09
  • When you use immediate=true, it does not fire any getters/setters. I wonder if that is the issue here. Can you include the backend for the relevant properties as well? – Adrian Larson Nov 4 '16 at 16:14
  • I just added more of the code to the original question. – Mauricio Oliveira Nov 4 '16 at 16:49
3
+50

Dependent picklist works fine with <apex:inputField> when you have static table. It erratically behaves with rerendering by <apex:inputField> (controlling picklist).

This strange behavior you can see at your 2nd image where without selecting controlling field i.e. Fee Type, the dependent field New\Renewal is selected. Moreover, this New value is not the actual value. If you click on the dropdown you will see there is one more duplicate New is displaying like this.

New/Renewal picklist

Now more interesting part, you thought that since New has already been selected in dependent picklist, so you are choosing the valid Fee Type (controlling) and saving this record. Right?

System will allow you to save but with blank dependent value.

Updated Workarounds after more investigation:

  1. You already have removed immediate= true from Remove button property. That's nice.

  2. Remove required=true property from Fee Type and License Category inputFields.

  3. To show both of them required (red vertical color), wrap those fields by <div class="requiredInput"><div class="requiredBlock"></div></div>

So, onclick of Remove button, system will not stop you removing the entries even if mandatory fields are not entered which is currently doing by actionSupport function.

code will look like this:

<apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Fee_Type__c.Label}">
                        <div class="requiredInput"><div class="requiredBlock"></div>
                            <apex:inputField value="{!s.Fee_Type__c}">
                                <apex:actionSupport event="onchange" reRender="lc"/>
                            </apex:inputField>
                        </div>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.Quantity.Label}">
                        <apex:inputField value="{!s.Quantity}" style="width:70px" required="true"/>
                    </apex:column>

                    <apex:column headerValue="{!$ObjectType.QuoteLineItem.Fields.License_category__c.Label}">
                        <div class="requiredInput"><div class="requiredBlock"></div>
                            <apex:inputField value="{!s.License_category__c}" id="lc"/>
                        </div>
                    </apex:column>
  1. Now to validate entries during save, write a small validation logic that Fee Type and License Category are mandatory, like below:

    try{ if(shoppingCart.size()>0) {

                for(quoteLineItem obj:shoppingCart)
                {
                    if(String.isBlank(obj.Fee_Type__c) || String.isBlank(obj.License_category__c))
                    {
                        throw new MyCustomException('Fee Type and License Category are mandatory');
                    }
                }
    
                upsert(shoppingCart);
            }
        }
        catch(Exception e){
            ApexPages.addMessages(e);
            return null;
        } 
    
  2. create myCustomException class which will throw custom exceptions.

public class MyCustomException extends Exception{}

Finally, during test, if you try to save without providing mandatory inputs it will throw the error like this.

Fee Type is mandatory

  • Thank you for the detailed answer. I will think about the proposed workaround, but it will be an extra effort to keep that visualforce selectList control updated, since sometimes I need to change those options from QLI fields, so I would need to update the visualforce page as well (and remember to do so). As I said before, I already removed the 'immediate=True' restriction and now new items are validated before removing an item in the cart, which stopped the weird behavior I declared.I will wait some time to see other errors still keep occurring before taking other actions. Thanks. – Mauricio Oliveira Nov 8 '16 at 10:38
  • I have updated entire answer and remembering my advanced dev assignment days. It will solve your issue with nicer UI experience. – Santanu Boral Nov 9 '16 at 1:06
  • 1
    Thank you @Santanu, I liked the idea of making them required through div and checking through code. Will add that strategy to my skill set for this and next developments. Thanks for your time. – Mauricio Oliveira Nov 9 '16 at 11:14
3

I found a similar problem on the developer forums from 2008 - If immediate="true" rerender does not appear to work. The general problem was that immediate="true" wasn't well suited to scenarios where you would bypass validation on inputfields and then come back to the page to continue working with the controls that had been bypassed.

The recommendation in that post was to remove the required attributes from the Visualforce markup and move the validation into the server side controller. Hence you would no longer need to use the immediate attribute. You could compliment this with your own JavaScript to do the validation as required.

  • Thanks, @Daniel. Good to know that it seems to be a real issue, I was really surprised by this behavior I got. I did remove already the immediate=true. This changes the page behavior and user workflow a little bit, but it seems to be more consistent. I honestly don't like adding JavaScript to control page behavior in Salesforce (also don't have much expertise with it), but I will consider this option if the problem persists. Thank you. – Mauricio Oliveira Nov 8 '16 at 10:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.