Wednesday, December 3, 2008

CRM 4.0 Supported Multi Select (Picklist) Control




As you all know Dynamics does not support multi select controls. Of course this is not entirely true since from a design perspective having the ability to create many to many associations and using ms grid is a control none the less. Having that said, Customers will find unbelievable reasons to why they need or prefer to use a multi select control and not the out of the box grid.

I was wondering what it would take to create a fully supported multi select control (in other words a listbox)
using dynamics OOB/supported capabilities.

Before I start let me say that the idea of creating such a control does not justify the amount of code needed to make this solution run inside ms supported boundaries. But then again, once you have it, it takes approximately 20 minutes (per listbox) to implement, which is fairly reasonable.

In order to prove my point and satisfy my wonderings I devised a simple scenario of a credit card listbox on the contact entity.
The idea was to be able to relate multiple credit card types to a single contact using a regular listbox.

The first thing I did is create the necessary customizations needed to support the solution.
STEP 1 - Customizations:
1. A new entity called credit card.






2. A new N:N relationship with the contact entity (e.g. gi_gi_creditcard_contact). When you create the relationship don’t change its name. The way ms represent its relationships (e.g. prefix_entity_entity) will help you to better understand this solution.





3. Added a few Credit card types to complete the list.


4. On the contact entity I created a credit card IFRAME:






5. Added a new contact attribute called creditcarditems; The reason for this attribute is that it is used as a transport that will hold the current selected options.

Note: Notice that the attribute schema name (gi_creditcard) is the same as the credit card entity schema name. This is important since the multipicklist.aspx refers to its parent contact transport attribute by using the Entity2Name witch is gi_creditcard. You may also add a query string parameter to the contact onload event that opens the multipicklist.aspx and use that instead.



6. Added the following script to the contact entity. The script hides the transport attribute and points the iframe to an external multipicklist.aspx.
The multipicklist.aspx receives the following querystring parameters:
contact objected (crmForm.ObjectId)
The name of the entity (crmForm.ObjectTypeName)
The name of the related entity (gi_creditcard)
The name of the storage (transport) attribute (gi_creditcard).
Organization Name e.g. ORG_UNIQUE_NAME


crmForm.gi_creditcard.style.display = "none";
var url = prependOrgName("/isv/controls/multipicklist.aspx");
url += "?toentity=gi_creditcard" ;
url += "&toattribute=gi_card";
url += "&fromentity=" + crmForm.ObjectTypeName;
url += "&id=" + (crmForm.ObjectId?crmForm.ObjectId:"");
url += "&orgname=" + ORG_UNIQUE_NAME;
document.all["IFRAME_CreditCards"].src = url;


STEP 2 – Build MultiPicklist.aspx Page (control)
1. Added a new asp.net web application and created a vdir under the isv folder.





2. Changed the default.aspx page to multipicklist.aspx and added the following html. The html contains a runat server listbox ,a script that binds to the parent (contact) onsave event and fills the transport attribute with selected values and a few styling rules to make the listbox occupy the entire iframe space.


<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<style type="text/css">
.LISTBOX
{
width:100%;
height:103%;
border:0px solid;
}
</style>
<script language="javascript">


MultiPicklist = {};
MultiPicklist.CrmForm = null;
MultiPicklist.Storage = null;
MultiPicklist.OnLoad = function()
{
MultiPicklist.CrmForm = parent.document.all.crmForm;
MultiPicklist.Storage = MultiPicklist.CrmForm.<%=Entity2Name%>;
MultiPicklist.CrmForm.attachEvent( "onsave" , MultiPicklist.OnSave );
}
MultiPicklist.OnSave = function()
{
var items = "";
var MultiBox = document.all.MultiBox;
for( var i = 0 ; i < MultiBox.options.length ; i++ )
{
if( MultiBox.options[i].selected == true )
items += MultiBox.options[i].value + ",";
}
MultiPicklist.Storage.DataValue = items.replace(/,$/i,"");
}


</script>
</head>
<body leftmargin="0" onload="MultiPicklist.OnLoad()" scroll="no" topmargin="0">
<form id="form1" runat="server">
<ASP:LISTBOX CSSCLASS="listbox" ID="MultiBox" SELECTIONMODE="Multiple" RUNAT="server">
</ASP:LISTBOX>
</form>
</body>
</html>


3. In the code behind I added functionality which:
a. Validates the query string parameters (Page_ValidateParameters())
b. Retrieves the credit card list from CRM (Page_RetrievePicklistOptions())
c. Marks the selected options if they exist (Page_SetSelectedOptions())


using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Xml;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.Sdk.Query;
using Microsoft.Crm.SdkTypeProxy;

namespace Controls
{
public partial class multipicklist : System.Web.UI.Page
{
/// <summary>
/// Current Organizaton Name
/// </summary>
private String OrganizationName;
/// <summary>
/// The Primary Entity (contact) ObjectId (crmForm.ObjectId)
/// </summary>
private Guid ObjectId;
/// <summary>
/// The Primary Entity (contact) Name (typename)
/// </summary>
private String Entity1Name;
/// <summary>
/// The Related N:N Entity (gi_creditcard) Name
/// This should also be the name of the transport attribute
/// on the Primary Entity (gi_creditcard attribute on contact entity)
/// </summary>
protected String Entity2Name;
/// <summary>
/// The Related Entity PrimaryField Name (gi_card)
/// This is the Picklist Option Text
/// </summary>
private String Entity2TextAttribute;
/// <summary>
/// The Primary Entity PrimaryKey Name (contactid)
/// </summary>
private String Entity1KeyAttribute;
/// <summary>
/// The Related N:N Entity PrimaryKey Name (gi_creditcardid)
/// </summary>
private String Entity2KeyAttribute;
/// <summary>
/// CrmService
/// </summary>
private CrmService Service;

protected void Page_Load(object sender, EventArgs e)
{
Page_ValidateParameters();
Page_CreateService();
Page_RetrievePicklistOptions();
Page_SetSelectedOptions();
}
/// <summary>
/// Retrieve Options From Relationship Entity Using Fetch Message
/// And Set the Listbox Selected Items
/// </summary>
private void Page_SetSelectedOptions()
{
if (!ObjectId.Equals(Guid.Empty))
{
String relationShipName = String.Format("gi_{0}_{1}", Entity2Name, Entity1Name);
StringBuilder fetchXml = new StringBuilder();
fetchXml.Append("<fetch mapping='logical'>");
fetchXml.Append("<entity name='").Append(relationShipName).Append("'>");
fetchXml.Append("<all-attributes />");
fetchXml.Append("<filter>");
fetchXml.Append("<condition attribute='contactid' operator='eq' value ='");
fetchXml.Append(ObjectId).Append("' />");
fetchXml.Append("</filter>");
fetchXml.Append("</entity>");
fetchXml.Append("</fetch>");

String resultXml = Service.Fetch(fetchXml.ToString());
XmlDocument fetchResult = new XmlDocument();
fetchResult.LoadXml(resultXml);
XmlNodeList relatedResults = fetchResult.SelectNodes("resultset/result");

foreach (XmlElement item in relatedResults)
{
XmlElement Entity2KeyAttributeNode = (XmlElement)item.SelectSingleNode(Entity2KeyAttribute);
ListItem CurrentItem = MultiBox.Items.FindByValue(Entity2KeyAttributeNode.InnerText.ToLower());
if( CurrentItem != null ) CurrentItem.Selected = true;
}
}
}
/// <summary>
/// Retrieve the listbox options from the related entity using
/// queryexpression and fill the listbox.
/// </summary>
private void Page_RetrievePicklistOptions()
{
QueryExpression creditQuery = new QueryExpression(Entity2Name);
creditQuery.ColumnSet.AddColumn(Entity2TextAttribute);
creditQuery.ColumnSet.AddColumn(Entity2KeyAttribute);
creditQuery.Distinct = true;
creditQuery.Orders.Add(new OrderExpression(Entity2TextAttribute, OrderType.Ascending));

RetrieveMultipleRequest retrieveMultipleRequest = new RetrieveMultipleRequest();
retrieveMultipleRequest.Query = creditQuery;
retrieveMultipleRequest.ReturnDynamicEntities = true;
RetrieveMultipleResponse retrieveMultipleResponse =
(RetrieveMultipleResponse)Service.Execute(retrieveMultipleRequest);

MultiBox.Items.Add( "" );
foreach (DynamicEntity creditcard in retrieveMultipleResponse.BusinessEntityCollection.BusinessEntities)
{
Key key = creditcard.Properties[Entity2KeyAttribute] as Key;
ListItem item = new ListItem(
creditcard.Properties[Entity2TextAttribute] + "",
key.Value.ToString("B"));
MultiBox.Items.Add(item);
}
}
/// <summary>
/// Create the crmservice
/// </summary>
private void Page_CreateService()
{
CrmAuthenticationToken token = new CrmAuthenticationToken();
token.AuthenticationType = 0;
token.OrganizationName = OrganizationName;

Service = new CrmService();
Service.CrmAuthenticationTokenValue = token;
Service.PreAuthenticate = false;
Service.UnsafeAuthenticatedConnectionSharing = true;
Service.Credentials = System.Net.CredentialCache.DefaultCredentials;

Service.Url = String.Format("{0}://{1}:{2}/mscrmservices/2007/crmservice.asmx", Request.Url.Scheme, Request.Url.Host, Request.Url.Port);
}
/// <summary>
/// Validate the listbox control parameters
/// </summary>
private void Page_ValidateParameters()
{
Entity1Name = Request.QueryString["fromentity"];
if (Entity1Name + "" == String.Empty)
throw new Exception("Entity1 Name is Missing");

Entity2Name = Request.QueryString["toentity"];
if (Entity2Name + "" == String.Empty)
throw new Exception("Entity2 Name is Missing");

OrganizationName = Request.QueryString["orgname"];
if (OrganizationName + "" == String.Empty)
throw new Exception("Organization Name is Missing");

ObjectId = Request.QueryString["id"] + "" == String.Empty ? Guid.Empty :
new Guid(Request.QueryString["id"]);

Entity2TextAttribute = Request.QueryString["toattribute"];
if (Entity2TextAttribute + "" == String.Empty)
throw new Exception("Picklist Text Attribute is Missing");

Entity1KeyAttribute = Entity1Name + "id";
Entity2KeyAttribute = Entity2Name + "id";
}
}
}


STEP 3 – Closing the Cycle Using a Plug-in
So why do we need a Plug-in? The main reason is that from the client side perspective CRM will create the contact id (GUID) only after the record is saved! This leaves us with no other option than to associate selected options on the server using a Plug-in.

1. Added a new Class Library application



2. Signed the application by creating a Strong Name key

3. Added Plug-In code that removes existing options (Disassociates) and saves (Re-Associates) the current contact with the selected listbox (credit card) options. The plug-in receives the existing transport attribute saved options from the PreImage and disassociates these options then gets the newly selected options from the PostImage and Re-Associate them with the contact. The Plug-in needs to run on both Post Create and Post Update events. Following is the Plug-in Specifications, SolutionXml and Code













Plug-In Solution XML

<Register LogFile="Plug-in Registration Log.txt" Server="http://moss:5555" Org="MicrosoftCRM" Domain="" UserName="administrator">
<Solution SourceType="1" Assembly="MPPlugIn.dll" Id="28f2c17f-2a16-4ce7-9a98-8e627ad2aecf">
<PluginTypes>
<Plugin TypeName="MPPlugIn.MPHandler" FriendlyName="3fe8c6c8-f928-43e3-b2e1-ccb55d651d6c" Id="93ecf707-c794-47df-a76e-9df3a3ee8e00">
<Steps>
<Step PluginTypeName="MPPlugIn.MPHandler" PluginTypeFriendlyName="3fe8c6c8-f928-43e3-b2e1-ccb55d651d6c" CustomConfiguration="" SecureConfiguration="" Description="Create of contact in Parent Pipeline" FilteringAttributes="" ImpersonatingUserId="" InvocationSource="0" MessageName="Create" Mode="0" PrimaryEntityName="contact" SecondaryEntityName="none" Stage="50" SupportedDeployment="0" Rank="1" Id="a0631824-7fc0-dd11-b17c-0003ff2f0264">
<Images>
<Image EntityAlias="gi_gi_creditcard_contact" ImageType="1" MessagePropertyName="Id" Attributes="contactid,gi_creditcard" Id="405a617d-7fc0-dd11-b17c-0003ff2f0264" />
</Images>
</Step>
<Step PluginTypeName="MPPlugIn.MPHandler" PluginTypeFriendlyName="3fe8c6c8-f928-43e3-b2e1-ccb55d651d6c" CustomConfiguration="" SecureConfiguration="" Description="Update of contact in Parent Pipeline" FilteringAttributes="" ImpersonatingUserId="" InvocationSource="0" MessageName="Update" Mode="0" PrimaryEntityName="contact" SecondaryEntityName="none" Stage="50" SupportedDeployment="0" Rank="1" Id="a0d1a8fe-82c0-dd11-b17c-0003ff2f0264">
<Images>
<Image EntityAlias="gi_gi_creditcard_contact" ImageType="2" MessagePropertyName="Target" Attributes="contactid,gi_creditcard" Id="f0d85819-83c0-dd11-b17c-0003ff2f0264" />
</Images>
</Step>
</Steps>
</Plugin>
</PluginTypes>
</Solution>
</Register>


Plug-In Code:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.SdkTypeProxy;

namespace MPPlugIn
{
public class MPHandler : IPlugin
{
#region IPlugin Members

/// <summary>
/// A Multipicklist context Object
/// </summary>
public class MPContext
{
/// <summary>
/// MS Context
/// </summary>
public IPluginExecutionContext context;
/// <summary>
/// The Entity 1 (Primary: contact in this case) Primary Key e.g. contactid
/// </summary>
public String Entity1PrimaryAttributeName;
/// <summary>
/// The Entity 2 (related: gi_creditcard in this case) Name
/// </summary>
public String Entity2Name;
/// <summary>
/// An Entity 1 Attribute that is used as a transport form selected options.
/// in this case i called it gi_creditcard (same and the related entity name)
/// You can give it any name you like.
/// </summary>
public String Entity1PicklistOptionsAttribute;
/// <summary>
/// The relationship name of contact and gi_creditcard
/// e.g. gi_gi_creditcard_contact. this is also used as the
/// PreImage and PostImage ALIAS names.
/// </summary>
public String N2NRelationShipName;
/// <summary>
/// Entity 1 (Primary) Name
/// Primary means the entity where the multipicklist is put.
/// Related means the entity from which the multipicklist options are taken.
/// </summary>
private String _Entity1Name;
public String Entity1Name
{
get
{
return this._Entity1Name;
}
set
{
this._Entity1Name = value;
this.Entity1PrimaryAttributeName = value + "id";
}
}
/// <summary>
/// Initialize a new MP context
/// </summary>
/// <param name="context"></param>
public MPContext(IPluginExecutionContext context) {
this.context = context;
}
}
/// <summary>
/// PlugIn Implementation:
/// 1. Create a new MPContext
/// 2. Fill the MPContext parameters
/// 3. Disassociate old options
/// 4. Associate new options
/// </summary>
/// <param name="context"></param>
public void Execute(IPluginExecutionContext context)
{
MPContext mpContext = new MPContext(context);
mpContext.Entity1Name = "contact";
mpContext.Entity2Name = "gi_creditcard";
mpContext.Entity1PicklistOptionsAttribute = "gi_creditcard";
mpContext.N2NRelationShipName = "gi_gi_creditcard_contact";

DisAssociateMultiplePicklist(mpContext);
AssociateMultiplePicklist(mpContext);
}
/// <summary>
/// Retrieves the existing options from the storage (transport) attribute
/// on the primary entity (contact) and call DisassociateEntitiesRequest
/// to remove the association
/// </summary>
/// <param name="context"></param>
private void DisAssociateMultiplePicklist(MPContext context)
{
IPluginExecutionContext msContext = context.context;
/*
Validates that the relationship name (e.g. gi_gi_creditcard_contact)
is the name you gave the PreImage Alias.
*/
if (msContext.PreEntityImages.Properties.Contains(context.N2NRelationShipName) == true)
{
//Retrieve the PreImage dynamic (Primary) entity 1 (contact)
DynamicEntity Entity1 = msContext.PreEntityImages.Properties[context.N2NRelationShipName] as DynamicEntity;
/*
Validates that the image contains the primarykey (contactid) in the property bag.
the contactid represents one side of the link (Moniker).
*/
if (Entity1.Properties.Contains(context.Entity1PrimaryAttributeName) == false)
return;
/* Extract the id (contactid) */
Guid primaryEntityId = ((Key)Entity1.Properties[context.Entity1PrimaryAttributeName]).Value;
/*
If the options (transport) attribute is missing
meaning that the its value is null then exit
*/
if( Entity1.Properties.Contains( context.Entity1PicklistOptionsAttribute ) == false )
return;
/* get the existing options */
String strOptions = Entity1.Properties[context.Entity1PicklistOptionsAttribute] + "";
if (strOptions.Equals(String.Empty)) return;

/* create an option array */
String[] mpOptions = strOptions.Split(',');
/* create a crmservice */
ICrmService Service = msContext.CreateCrmService(true);

/* for each option disassociate (remove) the link */
foreach (String option in mpOptions)
{
Guid relatedEntityId = new Guid(option);
DisassociateEntitiesRequest assocRequest = new DisassociateEntitiesRequest();
assocRequest.Moniker1 = new Moniker(context.Entity1Name, primaryEntityId);
assocRequest.Moniker2 = new Moniker(context.Entity2Name, relatedEntityId);
assocRequest.RelationshipName = context.N2NRelationShipName;
Service.Execute(assocRequest);
}
}
}
/// <summary>
/// Associates the new selected options with the primary entity (contact)
/// using the selected options taken from the PostImage Propery bag.
/// See DisAssociateMultiplePicklist method for code details.
/// </summary>
/// <param name="context"></param>
private void AssociateMultiplePicklist( MPContext context )
{
IPluginExecutionContext msContext = context.context;

if (msContext.PostEntityImages.Properties.Contains(context.N2NRelationShipName) == true)
{
DynamicEntity Entity1 = msContext.PostEntityImages.Properties[context.N2NRelationShipName] as DynamicEntity;

if (Entity1.Properties.Contains(context.Entity1PrimaryAttributeName) == false)
return;

Guid primaryEntityId = ((Key)Entity1.Properties[context.Entity1PrimaryAttributeName]).Value;

if (Entity1.Properties.Contains(context.Entity1PicklistOptionsAttribute) == false)
return;

String strOptions = Entity1.Properties[context.Entity1PicklistOptionsAttribute] + "";
if (strOptions.Equals(String.Empty)) return;

String[] mpOptions = strOptions.Split(',');
ICrmService Service = msContext.CreateCrmService(true);

foreach (String option in mpOptions)
{
Guid relatedEntityId = new Guid(option);
AssociateEntitiesRequest assocRequest = new AssociateEntitiesRequest();
assocRequest.Moniker1 = new Moniker(context.Entity1Name, primaryEntityId);
assocRequest.Moniker2 = new Moniker(context.Entity2Name, relatedEntityId);
assocRequest.RelationshipName = context.N2NRelationShipName;
Service.Execute( assocRequest );
}
}
}

#endregion
}
}


In order to add another relationship multi select control you only need to repeat step 1 ( customizations ) and part of step 3 ( Plug-In Registration and duplicating the Execute Method).

15 comments:

Anonymous said...

very interesting, looking forward to test this implementation. Great! Thanks

Jim Wang said...

Brilliant one!! Cheers Adi!

Anonymous said...

Great bit of code. Although, when the form loads the IFrame doesn't display the picklist, the IFrame is blank, but if I view the source code the "select" object is there and the HTML looks fine. I have to right click the IFrame and refresh it for it to render the picklist. Do you have any idea why this might be?

Thanks.

Adi Katz said...

Hi Bob,

This is just a hunch… try removing the doctype (At the top of the page) element form the multipicklist.aspx page.

Abhijeet said...

I found trouble while registering PlugIn, I am using PluginregisteringTool version 2.2,
I didnt getting from where should I take dll(assembly file) file, can you please explain in depth STEP 3 – Closing the Cycle Using a Plug-in , I successfully installed my assembly in GAC, but not able to browse assembly and load it , please help me, I am stuck on here... abhijeet

Abhijeet said...
This comment has been removed by the author.
Abhijeet said...

I have successfully registered assembly ! but still have error
No attribute

and if I try to access multipicklist page directly
Entity1 Name is Missing ,
I didnt getting

url += "&toattribute=gi_card";

where is this attribute or is it sas_creditcard ?

I think I am very close to see my multipicklist .. please tell me if anything wrong ...


Abhijeet

Adi Katz said...

Are you still stuck with this? If so, open a thread on ms forums, direct the thread to me and I’ll try to help you out.

Adi

Abhijeet said...

Thanks Adi,
I have created thread on ms forum,

http://forums.microsoft.com/Dynamics/ShowPost.aspx?PostID=4288656&SiteID=27

I put more error details there, please have look at it.

-abhijeet

Anonymous said...

I have an unrelated question. If you have an N:N relationship how do you set the values to be required or not Null. For example, what if I said that each person must have atleast one credit card. Is there a away to do this without custom code? I considered adding a looup but then that lets me select just one card not many. The default CRM implementation for N:N relationships does not (to the best of my knowledge) let me set required constraints. Well at least I dont know how...

Thanks

firedave said...

Hi Adi,

I have implemented this solution and have a question. When the plugin creates the associations between your credit cards entity and the contact entity, does this become searchable. For example, Can I now find all the contacts that have chosen both American Express and Visa as credit card types?

Adi Katz said...

Yes! one of the benefits of using this solution is that it uses genuine associations.

The multipicklist code behind also retrieves the selected options by searching CRM
(see page_setselectedoptions function at line 68).

You may also add the N:N relationship to the form (left navigation) and see the options selected in the multipicklist.

firedave said...

I agree and can see that the relationship is created. When the N:N relationship to the form (left navigation) isn't added then you are unable to do an advance find between the contact and credit card entities. However when you add that relationship onto the form it then appears as an option.

Really brilliant control - keep up the excellent articles.

Anonymous said...

I have included the multi-select the second tab, then no content is displayed. Only after the update of the IFrame you get to see.

Chris said...

HELP!!! I am not a programmer and I have been trying to get this thing to work for two days now. Right around step two is where I get totally lost. Where should I be pasting the code? I pasted everything behind multipicklist.aspx. I'm really not sure what to do after creating the .snk key. PLEASE PLEASE Help me!