Hi,
I'm adding this post, to share some of the development we done to fit the Salesforce Opportunity Product into our needs.
First of all please allow me to thank michaelforce (http://www.michaelforce.org/) for his great help, most of the code
comes from him.
I've just customized it, added a second search/filter method, a 'Select All'
We do not actually 'sell' products, but provide 'services' as a configuration, therefore we do not really use prices / quantities / amounts, but these can be added, and need to :
1. Keep track of the configurations
2. Send out configuration orders to other departments
We had the need to create at least one Visual force page (actually we created more, since we use a number of Opportunity record types)
The page includes a javascript to help with the search.
and actually is made of two parts :
- upper, we named it 'shopping cart'
- lower, the 'product basket'
in the middle we have the search bar, which allows two different type of search, and eventually allows to add in one go all the searched results.
here's the code for the page :
<apex:page standardController="Opportunity" extensions="opportunityProductEntryExtension" sidebar="false" action="{!priceBookCheck}" >
<apex:sectionHeader Title="Manage {!$ObjectType.Product2.LabelPlural}" subtitle="{!opportunity.Name}"/>
<apex:messages style="color:red"/>
<apex:outputText value="{!opportunity.RecordType.Name}"/>
<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 = 1;
var countDown = waitTime+1;
var started = false;
function resetTimer(){
countDown=waitTime+1;
if(started==false){
started=true;
runCountDown();
}
}
function runCountDown(){
countDown--;
if(countDown<=0){
fetchResults();
started=false;
}
else{
window.setTimeout(runCountDown,1000);
}
}
</script>
<apex:form >
<apex:outputPanel id="mainBody">
<apex:outputLabel styleClass="label">PriceBook: </apex:outputLabel>
<apex:outputText value="{!theBook.Name}"/>
<!-- 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">
<apex:pageblockTable value="{!shoppingCart}" var="s">
<apex:column >
<apex:commandLink value="Remove" action="{!removeFromShoppingCart}" reRender="selected,searchResults" immediate="true">
<!-- this param is how we send an argument to the controller, so it knows which row we clicked 'remove' on -->
<apex:param value="{!s.PriceBookEntryId}" assignTo="{!toUnselect}" name="toUnselect"/>
</apex:commandLink>
</apex:column>
<!-- apex:column width="25px">
<apex:inputcheckbox value="{!checked}"/>
</apex:column-->
<apex:column headerValue="{!$ObjectType.Product2.LabelPlural}" value="{!s.PriceBookEntry.Product2.Name}"/>
<apex:column headerValue="{!$ObjectType.OpportunityLineItem.Fields.Profile__c.Label}" rendered="{!opportunity.Market__c != 'MTS DATA'}">
<apex:inputField value="{!s.Profile_CAT__c}" style="width:110px" required="true" onkeyup="refreshTotals();" rendered="{!opportunity.Market__c = 'MTS Cash Markets'}" />
<!-- apex:inputField value="{!s.Profile_CAT__c}" style="width:110px" required="true" onkeyup="refreshTotals();" rendered="{!opportunity.Market__c = 'MTS Live'}" /-->
<apex:inputField value="{!s.Profile_MMF__c}" style="width:110px" required="true" onkeyup="refreshTotals();" rendered="{!opportunity.Market__c = 'MTS Repo Markets'}" />
<apex:inputField value="{!s.Profile_BV__c}" style="width:110px" required="true" onkeyup="refreshTotals();" rendered="{!opportunity.Market__c = 'MTS Bondvision'}" />
<apex:inputField value="{!s.Profile_ACM__c}" style="width:110px" required="true" onkeyup="refreshTotals();" rendered="{!opportunity.Market__c = 'MTS ACM'}" />
</apex:column>
<apex:column headerValue="{!$ObjectType.OpportunityLineItem.Fields.Quantity.Label}" rendered="{!opportunity.Market__c = 'MTS DATA'}">
<apex:inputField value="{!s.Quantity}" style="width:70px" required="true" onkeyup="refreshTotals();" />
</apex:column>
<apex:column headerValue="{!$ObjectType.OpportunityLineItem.Fields.UnitPrice.Label}" rendered="{!opportunity.Market__c = 'MTS DATA'}">
<apex:inputField value="{!s.UnitPrice}" style="width:70px" required="true" onkeyup="refreshTotals();" />
</apex:column>
<apex:column headerValue="{!$ObjectType.OpportunityLineItem.Fields.Description.Label}">
<apex:inputField value="{!s.Description}" required="false"/>
</apex:column>
</apex:pageblockTable>
<apex:pageBlockButtons >
<apex:commandButton action="{!onSave}" value="Save"/>
<apex:commandButton action="{!onCancel}" value="Cancel" immediate="true"/>
<!-- apex:commandButton action="{!onadd}" value="Add Selected"/-->
</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" rendered="{!OR(opportunity.Market__c = 'MTS Cash Markets', opportunity.Market__c ='MTS DATA', opportunity.Market__c ='MTS Repo Markets')}"/>
<apex:actionFunction name="fetchResults1" action="{!updateAvailableList}" reRender="searchResults" status="searchStatus"/>
<apex:outputPanel styleClass="search" rendered="{!opportunity.Market__c != 'MTS DATA'}">
Sections
</apex:outputPanel>
<!-- apex:outputlabel value="Sections" for="values" /-->
<apex:selectList value="{!Sections}" size="1" id="values" rendered="{!opportunity.Market__c != 'MTS DATA'}" >
<apex:actionSupport event="onchange" action="{!updateAvailableList}" reRender="searchResults" />
<apex:selectOptions value="{!Sectionnames}"/>
</apex:selectList>
<apex:outputPanel styleClass="search">
</apex:outputPanel>
<apex:commandButton action="{!addAllToShoppingCart}" value="Add All Selected" reRender="selected,searchResults" immediate="true"/>
<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/>
<apex:outputPanel id="searchResults">
<apex:pageBlockTable value="{!AvailableProducts}" var="a">
<!-- apex:column width="25px">
<apex:inputcheckbox value="{!checked}"/>
</apex:column-->
<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.Description.Label}" value="{!a.Product2.Description}"/>
<apex:column >
<!-- command button in a column... neato -->
<apex:commandButton value="Select" action="{!addToShoppingCart}" reRender="selected,searchResults" immediate="true">
<!-- 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>
FYI - Sectionames is a way we use to select product baskets, to make search/organization easier. you can actually remove this if it does not fit your needs.
and here is the apex code :
public with sharing class opportunityProductEntryExtension {
public Opportunity theOpp {get;set;}
public String searchString {get;set;}
public String searchstring2 {get;set;}
public opportunityLineItem[] shoppingCart {get;set;}
public priceBookEntry[] AvailableProducts {get;set;}
public Pricebook2 theBook {get;set;}
public string recordtypeidname {get;set;}
public boolean checked {get;set;}
public String Sections{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 opportunityLineItem[] forDeletion = new opportunityLineItem[]{};
public opportunityProductEntryExtension(ApexPages.StandardController controller) {
// Need to know if org has multiple currencies enabled
multipleCurrencies = UserInfo.isMultiCurrencyOrganization();
// Get information about the Opportunity being worked on
if(multipleCurrencies)
theOpp = database.query('select Id, Pricebook2Id, Pricebook2.Name, CurrencyIsoCode from Opportunity where Id = \'' + controller.getRecord().Id + '\' limit 1');
else
theOpp = [select Id, Pricebook2Id, PriceBook2.Name, Market__c, Main_Profile__c, RecordType.Id, RecordType.name from Opportunity where Id = :controller.getRecord().Id limit 1];
//assegna il valore del recordtype alla variabile
recordtypeidname = theOpp.RecordType.name;
// If products were previously selected need to put them in the "selected products" section to start with
shoppingCart = [select Id, Product_Family__c, quantity, unitprice, discount, Profile_MMF__c, Profile_ACM__c, Profile_CAT__c, Profile_BV__c, Profile_IC__c, Description, PriceBookEntryId, PriceBookEntry.Name, PriceBookEntry.IsActive, PriceBookEntry.Product2Id, PriceBookEntry.Product2.Name, PriceBookEntry.PriceBook2Id from opportunityLineItem where (OpportunityId=:theOpp.Id) AND (Product_Family__c=:theOpp.Market__c)];
if (theOpp.Market__c=='MTS Live'){
shoppingCart = [select Id, Product_Family__c, quantity, unitprice, discount, Profile_MMF__c, Profile_ACM__c, Profile_CAT__c, Profile_BV__c, Profile_IC__c, Description, PriceBookEntryId, PriceBookEntry.Name, PriceBookEntry.IsActive, PriceBookEntry.Product2Id, PriceBookEntry.Product2.Name, PriceBookEntry.PriceBook2Id from opportunityLineItem where (OpportunityId=:theOpp.Id)];
}
// Check if Opp has a pricebook associated yet
if(theOpp.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 = theOpp.Pricebook2;
}
if(!forcePricebookSelection)
updateAvailableList();
}
// this is the 'action' method on the page
public PageReference priceBookCheck(){
// if the user needs to select a pricebook before we proceed we send them to standard pricebook selection screen
if(forcePricebookSelection){
return changePricebook();
}
else{
//if there is only one active pricebook we go with it and save the opp
if(theOpp.pricebook2Id != theBook.Id){
try{
theOpp.Pricebook2Id = theBook.Id;
update(theOpp);
}
catch(Exception e){
ApexPages.addMessages(e);
}
}
return null;
}
}
public String getChosenCurrency(){
if(multipleCurrencies)
return (String)theOpp.get('CurrencyIsoCode');
else
return '';
}
public void updateAvailableList() {
// We dynamically build a query string and exclude items already in the shopping cart
String qString = 'select Id, Pricebook2Id, IsActive, Product2.Name, Product2.Profile__c, Product2.Family, Product2.IsActive, Product2.Description, UnitPrice from PricebookEntry where IsActive=true and Pricebook2Id = \'' + theBook.Id + '\'';
String searchstring3 = Sections;
if(multipleCurrencies)
qstring += ' and CurrencyIsoCode = \'' + theOpp.get('currencyIsoCode') + '\'';
if(recordtypeidname!=null){
if (theOpp.Market__c=='MTS Live'){
qString+= 'and (Product2.Family = \'' + 'MTS Cash Markets' + '\')' ;
qString+= 'and (Product2.MTS_Live_Enabled__c = TRUE)' ;
}else{
qString+= 'and (Product2.Family = \'' + theOpp.Market__c + '\')' ;
}
}
// 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 + '%\')';
}
if(searchString3!=null) if (searchString3!='All'){
qString+= ' and (Product2.Name like \'%' + searchString3 + '%\' or Product2.Description like \'%' + searchString3 + '%\')';
}
Set<Id> selectedEntries = new Set<Id>();
for(opportunityLineItem d:shoppingCart){
selectedEntries.add(d.PricebookEntryId);
}
if(selectedEntries.size()>0){
String tempFilter = ' and Id not in (';
for(Id i : selectedEntries){
tempFilter+= '\'' + (String)i + '\',';
}
String extraFilter = tempFilter.substring(0,tempFilter.length()-1);
extraFilter+= ')';
qString+= extraFilter;
}
qString+= ' order by Product2.Name';
qString+= ' limit 101';
system.debug('qString:' +qString);
AvailableProducts = database.query(qString);
// We only display up to 100 results... if there are more than we let the user know (see vf page)
if(AvailableProducts.size()==101){
AvailableProducts.remove(100);
overLimit = true;
}
else{
overLimit=false;
}
}
public void addToShoppingCart(){
// This function runs when a user hits "select" button next to a product
for(PricebookEntry d : AvailableProducts){
if((String)d.Id==toSelect){
shoppingCart.add(new opportunityLineItem(OpportunityId=theOpp.Id, PriceBookEntry=d, unitprice=d.unitprice, Product_Family__c=d.Product2.family, Profile_ACM__c=theOpp.Main_Profile__c, Profile_CAT__c=theOpp.Main_Profile__c, Profile_BV__c=theOpp.Main_Profile__c, Profile_MMF__c=theOpp.Main_Profile__c, PriceBookEntryId=d.Id));
break;
}
}
updateAvailableList();
}
public void addAllToShoppingCart(){
// This function runs when a user hits "select" button next to a product
for(PricebookEntry d : AvailableProducts){
shoppingCart.add(new opportunityLineItem(OpportunityId=theOpp.Id, PriceBookEntry=d, Profile_ACM__c=theOpp.Main_Profile__c, Profile_CAT__c=theOpp.Main_Profile__c, Product_Family__c=d.Product2.family, Profile_BV__c=theOpp.Main_Profile__c, Profile_MMF__c=theOpp.Main_Profile__c, PriceBookEntryId=d.Id));
}
updateAvailableList();
}
public PageReference removeFromShoppingCart(){
// This function runs when a user hits "remove" on an item in the "Selected Products" section
Integer count = 0;
for(opportunityLineItem d : shoppingCart){
if((String)d.PriceBookEntryId==toUnselect){
if(d.Id!=null)
forDeletion.add(d);
shoppingCart.remove(count);
break;
}
count++;
}
updateAvailableList();
return null;
}
public PageReference onSave(){
theOpp.ChangedLineItems__c=true;
// If previously selected products are now removed, we need to delete them
if(forDeletion.size()>0)
delete(forDeletion);
upsert(theOpp);
// 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);
upsert(theOpp);
}
catch(Exception e){
ApexPages.addMessages(e);
return null;
}
// After save return the user to the Opportunity
return new PageReference('/' + ApexPages.currentPage().getParameters().get('Id'));
}
public PageReference onCancel(){
// If user hits cancel we commit no changes and return them to the Opportunity
return new PageReference('/' + ApexPages.currentPage().getParameters().get('Id'));
}
public PageReference changePricebook(){
// This simply returns a PageReference to the standard Pricebook selection screen
// Note that is uses retURL parameter to make sure the user is sent back after they choose
PageReference ref = new PageReference('/oppitm/choosepricebook.jsp');
ref.getParameters().put('id',theOpp.Id);
ref.getParameters().put('retURL','/apex/opportunityProductEntry?id=' + theOpp.Id);
return ref;
}
public List<SelectOption> getsectionnames(){
List<SelectOption> options = new List<SelectOption>();
List<Sections_Picklist__c> Sectionlist = new List<Sections_Picklist__c>();
Sectionlist = [Select Id, PicklistValue__c FROM Sections_Picklist__c WHERE family__c =: theOpp.Market__c ORDER BY name];
for (Integer j=0;j<sectionlist.size();j++) {
options.add(new SelectOption(sectionlist[j].PicklistValue__c,sectionlist[j].PicklistValue__c));
}
return options;
}
}
further to this you'll need a redirect page when pressing the add product button on the opportunity page :
<apex:page standardController="opportunityLineItem" extensions="opportunityProductRedirectExtension" action="{!redirect}">
</apex:page>
and the relative apexcode for the redirect :
public with sharing class opportunityProductRedirectExtension {
Id oppId;
// we are extending the OpportunityLineItem controller, so we query to get the parent OpportunityId
public opportunityProductRedirectExtension(ApexPages.StandardController controller) {
oppId = [select Id, OpportunityId from OpportunityLineItem where Id = :controller.getRecord().Id limit 1].OpportunityId;
}
// then we redirect to our desired page with the Opportunity Id in the URL
public pageReference redirect(){
return new PageReference('/apex/opportunityProductEntry?id=' + oppId);
}
}
Finally, as requested by Salesforce, you'll need a testclass for the two apex classes (I've made a single apex test class to test both :
@istest
private class opportunityProductEntryTests {
static testMethod void theTests(){
TestUtilities tu = TestUtilities.generateTest();
// this is an external class that will generate a number of records I will query below.
// I suggest creating a TestUtility class to be called by your test class instead of creating the
// records in each test class.
// This to save time, code and providing a clear code organization.
OpportunityLineItem oli = [select Id, PricebookEntryId, PricebookEntry.Pricebook2Id, PricebookEntry.Name, PriceBookEntry.Product2Id, OpportunityId, Opportunity.AccountId from OpportunityLineItem where Description='This is a test' limit 1];
system.debug('value dell id :' + oli.id);
////////////////////////////////////////
// test opportunityProductEntry
////////////////////////////////////////
// load the page
PageReference pageRef = Page.opportunityProductEntry;
pageRef.getParameters().put('Id',oli.OpportunityId);
Test.setCurrentPageReference(pageRef);
// load the extension
opportunityProductEntryExtension oPEE = new opportunityProductEntryExtension(new ApexPages.StandardController(oli.Opportunity));
// test 'getChosenCurrency' method
if(UserInfo.isMultiCurrencyOrganization())
System.assert(oPEE.getChosenCurrency()!='');
else
System.assertEquals(oPEE.getChosenCurrency(),'');
// we know that there is at least one line item, so we confirm
Integer startCount = oPEE.ShoppingCart.size();
system.assert(startCount>0);
//test search functionality without finding anything
oPEE.searchString = 'michaelforce is a hip cat';
oPEE.updateAvailableList();
system.assert(oPEE.AvailableProducts.size()==0);
//test remove from shopping cart
oPEE.toUnselect = oli.PricebookEntryId;
oPEE.removeFromShoppingCart();
system.assert(oPEE.shoppingCart.size()==startCount-1);
//test save and reload extension
oPEE.onSave();
oPEE = new opportunityProductEntryExtension(new ApexPages.StandardController(oli.Opportunity));
system.assert(oPEE.shoppingCart.size()==startCount-1);
// test search again, this time we will find something
oPEE.searchString = oli.PricebookEntry.Name;
oPEE.updateAvailableList();
system.assert(oPEE.AvailableProducts.size()>0);
// test add to Shopping Cart function
oPEE.toSelect = oPEE.AvailableProducts[0].Id;
oPEE.addToShoppingCart();
system.assert(oPEE.shoppingCart.size()==startCount);
// add required info and try save again
for(OpportunityLineItem o : oPEE.ShoppingCart){
o.quantity = 5;
o.unitprice = 300;
}
oPEE.onSave();
// query line items to confirm that the save worked
opportunityLineItem[] oli2 = [select Id from opportunityLineItem where OpportunityId = :oli.OpportunityId];
system.assert(oli2.size()==startCount);
// test search Sections BELGIUM
oPEE.searchString = '';
oPEE.updateAvailableList();
oPEE.Sections = 'MTS BELGIUM';
oPEE.updateAvailableList();
system.assert(oPEE.AvailableProducts.size()>0);
// test here the add all function
oPEE.addAllToShoppingCart();
system.assert(oPEE.shoppingCart.size()> 0);
// ********************
// test search Sections BELGIUM
oPEE.Sections = 'All';
oPEE.updateAvailableList();
system.assert(oPEE.AvailableProducts.size()>0);
// load the section names
oPEE.getsectionnames();
// test on new Opp (no pricebook selected) to make sure redirect is happening
Opportunity newOpp = new Opportunity(Name='New Opp',stageName='Pipeline',Amount=10,Type='Enable - Expanding business', Market__c='MTS Repo Markets',closeDate=System.Today()+30,AccountId=oli.Opportunity.AccountId);
insert(newOpp);
oPEE = new opportunityProductEntryExtension(new ApexPages.StandardController(newOpp));
oPEE.priceBookCheck();
//System.assert(oPEE.priceBookCheck()!=null);
// final quick check of cancel button
System.assert(oPEE.onCancel()!=null);
////////////////////////////////////////
// test redirect page
////////////////////////////////////////
// load the page
pageRef = Page.opportunityProductRedirect;
pageRef.getParameters().put('Id',oli2[0].Id);
Test.setCurrentPageReference(pageRef);
// load the extension and confirm that redirect function returns something
opportunityProductRedirectExtension oPRE = new opportunityProductRedirectExtension(new ApexPages.StandardController(oli2[0]));
System.assert(oPRE.redirect()!=null);
}
}
Hope this can be of help.
If anything not clear, and I'm afraid I'm not a good teacher. Please feel free to ask.
ciaociao,
Alex