Sunday, August 16, 2009

Using Unresolved Email Recipients in CRM 4.0

Unresolved emails are useful in that you can send an email to someone without having them setup as a system record (contact, account, lead, queue, user, ect).  Unresolved email addresses are turned off by default.  They should be used with caution.  If you send an email to an unresolved address it will not track the email to that lead/contact since one does not exist.  It also does not leverage the email "Do not allow" flags to allow people to opt out of emails.  With those considerations, there are still cases where it is very useful to send an email to someone without having to create a contact record.



I will show two example of sending emails to unresolved recipients.  The first will be sent from the email form using JavaScript.  The second will be done in a plugin.  We will add two unresolved email addresses as CC addresses on emails sent from a contact record.


Before we begin.  You must flip the setting to allow unresolved recipients.  Go to Settings --> Administration --> System Settings --> Email Tab.  Set Allow messages with unresolved e-mail recipients to be sent to yes.




 Second, we will add a couple of fields to the contact for secondary contacts email addresses.  These fields will contain our unresolved email addresses.




JavaScript Implementation
When the user clicks "Send Email" from the contact form we want the CC field to default with the two unresolved email addresses.  To do this we add the following Jscript to the onload of the email form.  The code will check to see if the regarding object is of type contact since this form is used for all emails.  Then it adds each email address to an email object that follows the activityparty schema for unresolved email addresses.  It has a type of 9206 and the email is set in the data property.

if (crmForm.FormType == 1 &&
    crmForm.all.regardingobjectid.DataValue != null &&
    crmForm.all.regardingobjectid.DataValue[0].typename == "contact" &&
    window.opener != null &&
    window.opener.document != null) {



    var ar = new Array();
    var emailObj = new Object();


    if (window.opener.document.crmForm.all.new_secondarycontactemail1 != null &&
            window.opener.document.crmForm.all.new_secondarycontactemail1.DataValue != null) {
        emailObj = new Object();
        emailObj['type'] = '9206';
        emailObj['category'] = '3';
        emailObj['data'] = window.opener.document.crmForm.all.new_secondarycontactemail1.DataValue;
        emailObj['name'] = window.opener.document.crmForm.all.new_secondarycontactemail1.DataValue;
        ar.push(emailObj);
    }



    if (window.opener.document.crmForm.all.new_secondarycontactemail2 != null &&
            window.opener.document.crmForm.all.new_secondarycontactemail2.DataValue != null) {
        emailObj = new Object();
        emailObj['type'] = '9206';
        emailObj['category'] = '3';
        emailObj['data'] = window.opener.document.crmForm.all.new_secondarycontactemail2.DataValue;
        emailObj['name'] = window.opener.document.crmForm.all.new_secondarycontactemail2.DataValue;
        ar.push(emailObj);
    }



    crmForm.all.cc.DataValue = ar;


}


Lastly, publish the customization.  When you click "Send Email" from the contact form your addresses will be added to the CC. 


 


 


Plugin Implementation


The same functionality can be added using a plugin.  The benefits of using a plugin versus a client side implementation is that you can implement consistent functionality regardless of whether the email send is triggered from a client portal, another plugin, or through the CRM UI.  The JavaScript implemenation is useful in that the user can see who the email is being sent to before they click Send. 



Install the plugin using the registration tool as a pre-create step for email save.  This will add the CC on the initial save of the email.  It is triggered regardless of whether the user clicks save or send from the email form.  The code also includes duplicate checking logic. So, if the user adds the email address to the CC from the form it will not duplicate the same address in the plugin.


using System.Web;
using System.Net;


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Crm.Sdk.Query;
using System.Reflection;
using System.Web.Services.Protocols;
using Microsoft.Crm.SdkTypeProxy.Metadata;
using Microsoft.Crm.Sdk.Metadata;
using Inetium.CrmPlugins;
using System.Collections;



namespace Inetium.CrmPlugins.Plugins
{
    public class CaseUnresolvedEmailPlugin : IPlugin
    {
        public void Execute(IPluginExecutionContext context)
        {
            ICrmService service = null;
            try
            {
                if (context.InputParameters.Properties.Contains("Target") &&
                    context.InputParameters.Properties["Target"] is DynamicEntity)
                {
                    DynamicEntity entityInput = context.InputParameters.Properties["Target"] as DynamicEntity;
                    if (entityInput.Properties.Contains("regardingobjectid"))
                    {
                        Lookup regardingObjectLookup = entityInput.Properties["regardingobjectid"] as Lookup;
                        service = context.CreateCrmService(true);
                       
                        // Only using this for contacts
                        if (regardingObjectLookup.type == EntityName.contact.ToString())
                        {
                            // Pull parent contact - using helper method
                            DynamicEntity ctn = CRMUtilities.RetrieveById(service, EntityName.contact.ToString(), "contactid", regardingObjectLookup.Value, new ColumnSet(new string[]{"new_secondarycontactemail1", "new_secondarycontactemail2"}));
                            if (!ctn.Properties.Contains("new_secondarycontactemail1") && !ctn.Properties.Contains("new_secondarycontactemail2"))
                                return;



                            string email1 = ctn.Properties.Contains("new_secondarycontactemail1") ? ctn.Properties["new_secondarycontactemail1"].ToString() : "";
                            string email2 = ctn.Properties.Contains("new_secondarycontactemail2") ? ctn.Properties["new_secondarycontactemail2"].ToString() : "";


                            DynamicEntity[] partyArrayExisting = entityInput.Properties.Contains("cc") ? entityInput.Properties["cc"] as DynamicEntity[] : new DynamicEntity[] { };
                            List<DynamicEntity> partyList = new List<DynamicEntity>(partyArrayExisting);



                            if (email1 != "" && !IsEmailAlreadyInCC(email1, partyList))
                            {
                                DynamicEntity party = new DynamicEntity();
                                party.Name = EntityName.activityparty.ToString();
                                party.Properties["addressused"] = email1;
                                partyList.Add(party);
                            }



                            if (email2 != "" && !IsEmailAlreadyInCC(email2, partyList))
                            {
                                DynamicEntity party = new DynamicEntity();
                                party.Name = EntityName.activityparty.ToString();
                                party.Properties["addressused"] = email2;
                                partyList.Add(party);
                            }



                            // Generate CC's list by adding in those from contacts
                            if (partyList.Count > 0)
                                entityInput.Properties["cc"] = partyList.ToArray();


                        }


                    }
                }
            }
            catch (SoapException se)
            {
                throw new Exception(se.Detail.InnerText);
            }
            catch (Exception ex)
            {
                throw new Exception(ex.Message);
            }
            finally
            {
                if (service != null)
                {
                    service.Dispose();
                    service = null;
                }
            }
        }



        private bool IsEmailAlreadyInCC(string email, List<DynamicEntity> activityPartyList)
        {
            bool bAlreadyExists = false;
            foreach (DynamicEntity existingParty in activityPartyList)
            {
                // compare to existing CCs
                if (existingParty.Properties.Contains("addressused") &&
                    (existingParty.Properties["addressused"].ToString() == email))
                {
                    bAlreadyExists = true;
                    break;
                }
            }
            return bAlreadyExists;
        }
    }
}



Dynamics CRM 4.0 - JavaScript Web Service Helper Objects Part II

In February I posted a helper library for making JavaScript call to CRM - Original Post. Since then I have made a few enhancements.




  • Adding ordering to your results

  • Allowing for Null/Not Null Checks


The below example is used to default the address on a new contact to an address for their parent account. It pulls the first address (ordering by address number) where the address line 1 is not null (not null check).


try {
if (crmForm.all.parentaccountid.DataValue != null) {
// Create object passing in the entity you are selecting from
var crmService = new CrmService("customeraddress", LOGICAL_OPERATOR_AND);
crmService.AddColumn("name");
crmService.AddColumn("line1");
crmService.AddColumn("line2");
crmService.AddColumn("line3");
crmService.AddColumn("city");
crmService.AddColumn("stateorprovince");
crmService.AddColumn("postalcode");
crmService.AddColumn("country");

// Order by address number ascending
crmService.AddOrder("addressnumber", "Ascending");



// Add filter conditions (note: the "AND" logical operator was specified in constructor)
crmService.AddFilterCondition("parentid", crmForm.all.parentaccountid.DataValue[0].id, CONDITION_OPERATOR_EQUAL);

// Where line1 is not null
crmService.AddFilterCondition("line1", "", "NotNull");



// Retrieve the result object
var result = crmService.RetrieveMultiple();


// Loop through rows and select values (they return strings)
if (result.Rows.length > 0) {
var row = result.Rows[0];
// Get Column By Name
crmForm.all.address1_name.DataValue = row.GetValue("name");
crmForm.all.address1_line1.DataValue = row.GetValue("line1");
crmForm.all.address1_line2.DataValue = row.GetValue("line2");
crmForm.all.address1_line3.DataValue = row.GetValue("line3");
crmForm.all.address1_city.DataValue = row.GetValue("city");
crmForm.all.address1_stateorprovince.DataValue = row.GetValue("stateorprovince");
crmForm.all.address1_postalcode.DataValue = row.GetValue("postalcode");
crmForm.all.address1_country.DataValue = row.GetValue("country");



}
}
}
catch (e) {
alert(e.message);


}


The new library is attached below. Enjoy!


-Andrew



This release is provided "AS IS" and contains no warranty and confers no rights.



ASP.Net Caching with Dynamics CRM Data

When writing websites that work with CRM data you have a couple options when it come to binding PickList attributes.  You can hard-code the options into list items.


        Account Relationship Type:
        <br />
        <asp:DropDownList runat="server" ID="accountRelationshipType">
            <asp:ListItem Value="1">Competitor</asp:ListItem>
            <asp:ListItem Value="2">Consultant</asp:ListItem>
            <asp:ListItem Value="3">Customer</asp:ListItem>
            <asp:ListItem Value="4">Investor</asp:ListItem>
            <asp:ListItem Value="5">Partner</asp:ListItem>
            <asp:ListItem Value="6">Influencer</asp:ListItem>
            <asp:ListItem Value="7">Press</asp:ListItem>
            <asp:ListItem Value="8">Prospect</asp:ListItem>
            <asp:ListItem Value="9">Reseller</asp:ListItem>
            <asp:ListItem Value="10">Supplier</asp:ListItem>
            <asp:ListItem Value="11">Vendor</asp:ListItem>
            <asp:ListItem Value="12">Other</asp:ListItem>
        </asp:DropDownList>



This option is tedious and it requires a code change if a system customizer adds another pick list value.  The second option is to hit the metadata service to retrieve the available options and then bind them to the dropdown list.  To pull back the metadata every time, performance is a concern, especially when you have multiple dropdown lists.  It is better to leverage ASP.Net caching to store the data.  You can set up a cache dependency to re-pull the data every X number of hours.


To get started you need to add a web reference to the MetaData web service.



The URL is http://andrewvpc:5555/mscrmservices/2007/metadataservice.asmx where andrewvpc is your servername and 5555 is your port number.  This does not require the uniquename as is necessary with the CRM service.



 


Setup MetaData Service Connection


Next, setup the connection by adding the following code.  The code is similar to connecting to the CRM service with a couple differences.


        private static String _org = System.Configuration.ConfigurationManager.AppSettings["CRMWSOrg"];
        private static String _userName = System.Configuration.ConfigurationManager.AppSettings["CRMWSUser"];
        private static String _password = System.Configuration.ConfigurationManager.AppSettings["CRMWSPassword"];
        private static String _domain = System.Configuration.ConfigurationManager.AppSettings["CRMWSDomain"];
        private static String CRM_WSURL = System.Configuration.ConfigurationManager.AppSettings["CRMWS"];
        private static String CRM_SERVER = System.Configuration.ConfigurationManager.AppSettings["CRMSERVER"];



        public static MetadataService.MetadataService InitiateMetaDataService(bool useSuperAdmin)
        {


            MetadataService.MetadataService service = new MetadataService.MetadataService();
            try
            {

                service.Url = CRM_SERVER + "metadataservice.asmx";

                if (useSuperAdmin)
                    service.Credentials = new NetworkCredential(_userName, _password, _domain);
                else
                    service.Credentials = System.Net.CredentialCache.DefaultCredentials;




                MetadataService.CrmAuthenticationToken token = new MetadataService.CrmAuthenticationToken();
                token.AuthenticationType = 0;
                token.OrganizationName = (string)System.Configuration.ConfigurationManager.AppSettings["CRMWSOrg"];


                service.CrmAuthenticationTokenValue = token;



                return service;
            }
            catch (Exception ex)
            {
                throw new Exception("Exception caught in InitiateMetaDataService: " + ex.Message);
            }
        }



 


Get PickList Data from MetaData Service


After connecting to the service, create a RetrieveAttributeRequest for a given entity and attribute.


        public static Option[] GetPickListValues(string entity, string attribute)
        {
            try
            {
                using (MetadataService.MetadataService service = CachingHelper.InitiateMetaDataService(true))
                {
                    RetrieveAttributeRequest req = new RetrieveAttributeRequest();
                    req.EntityLogicalName = entity;
                    req.LogicalName = attribute;
                    RetrieveAttributeResponse resp = service.Execute(req) as RetrieveAttributeResponse;
                    if (resp.AttributeMetadata as PicklistAttributeMetadata == null)
                        throw new ApplicationException("Not picklist attribute");



                    PicklistAttributeMetadata attributeMetaData = resp.AttributeMetadata as PicklistAttributeMetadata;
                    return attributeMetaData.Options;
                }
            }
            catch (SoapException ex2)
            {
                throw new Exception("Exception caught in GetPickListValues:  " + ex2.Detail.InnerText, ex2);



            }
            catch (Exception ex)
            {
                throw new Exception("Exception caught in GetPickListValues:  " + ex.Message);
            }
        }
 



Load Data from Cache


Use ASP.Net caching to store the data in the cache if it is expired.  Set a cache dependency of for X number of hours.  Retrieve the data from the cache and bind it to the drop down list.  The label is a little tricky because the label contains text for each culture.  The value is the int value associated with the PickList.

        public void LoadFromCache(string entityName, string attributeName, DropDownList list, int hoursToCache, Page page)
        {
            // If cache expired then reset in cache
            if (page.Cache[attributeName] == null)
            {
                // Get from CRM
                Option[] options = App_Code.CachingHelper.GetPickListValues(entityName, attributeName);
                page.Cache.Insert(attributeName, options, null, DateTime.Now.AddHours(hoursToCache), TimeSpan.Zero);
            }



            // Pull from cache
            Option[] optionsFromCache = page.Cache[attributeName] as Option[];
            list.Items.Clear();
            foreach (Option option in optionsFromCache)
            {
                string label = "";
                // Grab first label or add extra logic to grab the label for the
                // right culture
                if (option.Label.LocLabels.Length > 0)
                    label = option.Label.LocLabels[0].Label;



                list.Items.Add(new ListItem(label, option.Value.Value.ToString()));
            }
        }
       
    }


Create a Web Page


Create a web page and add a DropDownList .


Account Relationship Type:
<br />
<asp:DropDownList runat="server" ID="accountRelationshipType">
</asp:DropDownList>



 


Call Cached Data from Page


On page load bind the data to the DropDownList.


protected void Page_Load(object sender, EventArgs e)
        {
            if (!this.IsPostBack)
            {
                App_Code.CachingHelper helper = new CacheDemo.App_Code.CachingHelper();
                helper.LoadFromCache("account", "customertypecode", accountRelationshipType, 5, this);
            }
        }



 


Hit Run... ...  The data will be pulled from the CRM the first time and from the cache on subsequent calls.

Friday, May 8, 2009

Debugging Workflows and Plugins in Microsoft Dynamics CRM 4.0

The concept of debugging plugins and workflows has been blogged about in the past.  There is a lot of great information out there.  One thing I have noticed about debugging is that it involves a lot of steps.  These steps take time.  You end up waiting forever from the time you make a code change to the time that you are debugging again.  I wanted to share a couple things I have learned about debugging that have saved me a great deal of time. 


Time Wasters:



  • Starting and stopping IIS and the async service

  • Deploying your new assembly

  • Attaching to processes

  • Getting back to debugging after making a small code change


Let’s get started… …


Project Setup
I like to start with a vanilla system on a virtual PC (VPC) that has CRM installed.  I import the customizations into my environment and set up some quick test data. 


Note:  I stay away from remote debugging when possible as it requires specific security privileges on the CRM server and you will affect users that are trying to access the system. 


From my VPC I open the project that includes my workflow or plugin.  Be sure to set the build path to the bin/assembly folder of your CRM instance.  On my machine it is C:\Program Files\Microsoft Dynamics CRM\server\bin\assembly\.  It varies from installation to installation.   Building to this location allows us to register the plugin to disk and make code changes quickly without having to move files around or re-register anything.


Register Plugin
Open the registration tool and register your plugin or workflow.  Be sure to register the plugin to disk. 


Note:  When you move to production I recommend registering to the database, but registering to disk works great for debugging.


Attaching to Processes
Now that you have the plugin registered, you are ready to start debugging.  You need to attach to W3WP.exe to attach to plugins since they run within IIS.  To attach to workflows you need to attach to the CRM async service which is Crmasyncservice.exe.


 
Select w3wp.exe and Crmasyncservice.exe and click “Attach.”


To expedite the process I used a trick from Janne Mattila.  You leverage VS macros to attach to the processes by using a shortcut key. I have Ctrl+Shift+V tied to a script that attaches to both w3wp and crmasyncservice.   See my script below.


Courtesy of Janne Mattila:  http://blogs.msdn.com/jannemattila/archive/2008/10/30/attaching-debugger-to-w3wp-exe-using-nice-and-easy-keyboard-shortcut.aspx


Imports System
Imports EnvDTE
Imports EnvDTE80
Imports EnvDTE90
Imports System.Diagnostics


Public Module AttachHelper
    ' This subroutine attaches to w3wp.exe:
    Sub Attach()
        Dim attached As Boolean = False
        Dim proc As EnvDTE.Process


        For Each proc In DTE.Debugger.LocalProcesses
            If (Right(proc.Name, 8) = "w3wp.exe") Then
                proc.Attach()
                attached = True
            End If
        Next


        If attached = False Then
            MsgBox("Couldn't find w3wp.exe")
        End If


        attached = False
        For Each proc In DTE.Debugger.LocalProcesses
            If (Right(proc.Name, 19) = "CrmAsyncService.exe") Then
                proc.Attach()
                attached = True
            End If
        Next
        If attached = False Then
            MsgBox("Couldn't find crmasyncservice.exe")
        End If
    End Sub
End Module


So we are now able to quickly attach to processes.  We can debug.  Awesome!!!


 


But wait!  Next, you discover you need to make a code change.  We don’t have edit-and-continue available in plugins yet.  So, you stop the your session, make your code change and you get the following error when you try to build. 


 


This is because you are trying to build to a file location that is already locked by the async service and/or IIS.  To get around this I have a bat file on my desktop and I run it every time I want to rebuild.  It includes the following:
iisreset
net stop MSCRMAsyncService
net start MSCRMAsyncService
"C:\Program Files\Internet Explorer\iexplore.exe" http://andrewvpc:5555


This script restarts IIS to unlock w3wp.exe.  Second, it restarts the async service.  Lastly, I reopen the CRM website in IE, so IIS will re-spawn itself (replace andrewvpc with your servername).  Otherwise, when you try to attach to processes it won’t be able to find w3wp.exe.


 


Now that you have ran the script, try to rebuild.  You will be rid of your error message and it will build successfully.  Now re-run your macro using Ctrl+Shift+V.  You are back to debugging again. Enjoy!!!


 

Sunday, March 8, 2009

Tips for Productive JavaScript Development in Dynamics CRM

I came to CRM from an application development background.  I’ve lived in Visual Studio.  I’m use to having IntelliSense, debugging, and I am use to getting instant results when testing my code.  When I first started doing JavaScript development in CRM I found myself thinking "There has to be a better way to do this!"  I shouldn’t have to publish a change, and make five clicks just to retest a scenario.  I also shouldn’t have to blindly code and hope my code is free of syntax errors (no intellisense).


I have found six tips especially helpful in JavaScript development.  Granted, some of these topics are not ground-breaking, but if you put them all together you will find yourself a much more productive JavaScript developer.


1)  Use an external JavaScript file while doing development.  It will save you a lot of time.  Regularly you make a code change and then you need to save and publish every time you want to retest.  Yes, the preview feature is helpful some of the time, but it is still very time consuming.   Using an external file will also allow you to leverage Visual Studio which offers IntelliSense and syntax checking.


Add this code to your onload.  It loads a JS file from within your ISV folder.   It uses a query string parameter to prevent caching and it waits until the dom is loaded before it executes the code.


var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type= 'text/javascript';
script.src= '/ISV/Entity/accounttesting.js?noCache=' +  Math.random();
script.onreadystatechange= function () {
if (this.readyState == 'complete' || this.readyState == 'loaded')
{
document.OnLoadCode()
}
}
head.appendChild(script);


Second, you can add calls in your OnSave and OnChange events to call code in the JavaScript file.  This will allow you to do all development in the one file.
OnSave:  document.OnSaveCode();
OnChange of account name:  document.OnChangeCode_AccountName();



Third, add a file name accounttesting.js to your /ISV/Entity folder under the website folder.  It will contain the following code.
document.OnLoadCode = function()
{
   alert("OnLoad code");
}


document.OnSaveCode = function()
{
   alert("OnSave code");
}



document.OnChangeCode_AccountName = function()
{
    alert("OnChange of Account Code");
}


// other functions

Last, start testing.  Any time you make a code change to the file you can refresh the account screen (F5) and the newest code will be executed.  Instant results!!!


Disclaimer:  You should only use this for development.  After you have tested your changes using this method you should copy the code back into the OnLoad and comment out the code that loads the JavaScript file.  Keeping the file in your OnLoad will save you time and headaches in deployment.  Plus, this is not supported.


2)  Use the debugger statement in your code if you want to walk through code.  You are able to leverage Visual Studio to analyze variable values as you execute your code.  Very useful!


First, within Internet Explorer, uncheck “Disable script debugging  (Internet Explorer)” and “Disable script debugger (Other)”



Next, add following code before any code you would like to debug.


debugger;



3) Use a helper library for calling the CRM web service through AJAX.    Calling the CRM web service requires you to work with the XML request using string concatenation which is very error-prone.  You can use my helper objects Click Here to help make life easier.  Ascentium has one as well Click Here.  Their library uses fetchxml while mine uses RetrieveMultiple.



4) Use Intellisense to help prevent mistakes as you code.  The book CRM as a Rapid Development Platform comes with a excellent Intellisense generator for Visual Studio.  http://www.thecrmbook.com/Video/Customization/EnableCRMJavascriptIntellisense/viewvideo.aspx.  It is well worth the 50 dollar investment.


You point the generator application against your instance of CRM and it generates JavaScript files for you to reference in your JS file.  Now you have Intellisense.  No more copy and pasting the field name from the forms window to make sure you don’t mis-spell it.  No more bugs because you spelled the word crmForm as crmform.




 


5)  Follow the DRY principle.  Don’t repeat yourself.  If you need to hide a field both onload and when a specific dropdown is selected, don’t copy and paste the code around.  Add functions to OnLoad and call the function wherever you need to.  Also, as you find useful code, such as hiding and showing fields, create functions for them and re-use them from project to project.


6)  Use try catch statements where possible.  Depending on your browser setings, it may be hard to determine what error is actually being thrown, especially in OnLoad.  Catching the error message and displaying an alert will help save time while troubleshooting errors.


try

   // Implementation here
}
catch (er)
{
        alert(er.message);
}


 -Andrew


This code is provided "AS IS" and contains no warranty and confers no rights.

Account Hierarchies in CRM 4.0

I recently collaborated with Ryan McCormick and Yaniv Arditi on the Dynamics forums at https://community.dynamics.com. We created a hierarchical representation of accounts in CRM that can be added as either a tool bar button or as an IFrame within account. The page can also display an icon next to the account or it can just display the account name. The entries link to the actual account records.

Without Icons





With Icons




Query
When pulling hierarchy data, one concern is performance. Each level in the hierarchy would require a call to the CRM webservice. Instead we went with a RecursionCTE query that used the filtered views.

with RecursionCTE (parentaccountid, accountid, [name])
as
(
select R2.parentaccountid, R2.accountid, R2.name from filteredaccount as R2
where r2.accountid = @accountid
UNION ALL
select R1.parentaccountid, R1.accountid, R1.name from filteredaccount as R1
join RecursionCTE as R2 on R1.parentaccountid = R2.accountid
)
select R1.new_pictureurl, R1.accountid, R1.name, case when r1.accountid =@accountid then null else R1.parentaccountid end as parentaccountid
from filteredaccount as R1
JOIN RecursionCTE as R2
on R1.accountid = R2.accountid


Adding Nodes to Treeview
I used an ASP.Net TreeView to display the hierarchy. I loaded the data into a dataset using a relationship and iterated through the dataset, adding nodes. I also checked to see that only four levels of nodes are shown. The reset are collapsed so the screen doesn’t become too cluttered.

… …
dsResult.Tables[0].TableName = "Account";
DataRelation relation = new DataRelation("ParentChild",
dsResult.Tables["Account"].Columns["accountid"],
dsResult.Tables["Account"].Columns["parentaccountid"],
true);
relation.Nested = true;
dsResult.Relations.Add(relation);

DataTable dt = dsResult.Tables[0];
DataRow[] rows = dt.Select(string.Format("accountid = '{0}'", acctId));
if (rows != null && rows.Length > 0)
{
DataRow row = rows[0];
TreeNode node = GetNode(row, true);
TreeView1.Nodes.Add(node);
DataRow[] childRows = row.GetChildRows(relation);
if (childRows.Length > 0)
AddChildNodesRecursive(node, childRows, relation, 1);

}
}
}


private void AddChildNodesRecursive(TreeNode parentNode, DataRow[] dataRows, DataRelation relation, int depth)
{
foreach (DataRow row in dataRows)
{
TreeNode node = GetNode(row, false);
parentNode.ChildNodes.Add(node);

DataRow[] childRows = row.GetChildRows(relation);
if (childRows.Length > 0)
AddChildNodesRecursive(node, childRows, relation, depth + 1);

if(depth >= _maxTreeDepth - 1) // set to 4
node.Collapse();

}
}



Displaying Image and URL
I set the navigate URL to the URL of the account. There is also an option to set an image URL to display an icon for the account. If none exists then a default icon can be used.

private static TreeNode GetNode(DataRow row, bool isRoot)
{
TreeNode node = new TreeNode();
node.Text = " " + row["name"].ToString();
node.Target = "_new";
node.NavigateUrl = string.Format("/{0}/SFA/accts/edit.aspx?id=" + row["accountid"].ToString(), ORG); ;

if (row["inet_imageurl"] == DBNull.Value)
// Set to some default Image
node.ImageUrl = "/isv/photos/defaultimage.gif";
else
node.ImageUrl = row["inet_imageurl"].ToString();

return node;
}




Opening Window without Toolbar and Sized Correctly
I ran into a problem with the way the account window opens. The target is set to _new so it will open in a new window, however you don’t have any control over how the window is displayed (width, height, show toolbar, ect). I had to intercept the button click and use JavaScript to open the new window. Looking back, it would have been better to render the HTML myself rather than relying on the tree view control. However, it does the job.

I had to set the onclick of the TreeView to


function postBackByObject()
{
if(window.event.srcElement.href != null
&& window.event.srcElement.href != ""
&& window.event.srcElement.href.substr(0,4).toLocaleLowerCase() == "http"
&& window.event.srcElement.nameProp.substr(0,11).toLocaleLowerCase() != "webresource")
{
window.open(window.event.srcElement.href,'popup','toolbar=no,location=no,directories=no,status=yes,scrollbars=yes,menubar=no,resizable=yes,width=1000,height=560');
}

// clicks that are expanding the treeview come in with webresource - let them occur
if(window.event.srcElement.href != null
&& window.event.srcElement.href != ""
&& window.event.srcElement.href.substr(0,4).toLocaleLowerCase() == "http"
&& window.event.srcElement.nameProp.substr(0,11).toLocaleLowerCase() != "webresource")
{
event.returnValue = false;
return false;
}
}

Adding to IFRAME
To add the page as an IFrame add a tab to the account entity and add a section. Next, add an IFRAME. Set "Pass record object type-code and unique identifier as paramters" as true. Also allow cross side scripting.



Set the necessary number of rows. Allow scrolling as necessary.




The IFrame shows up in a new tab.




Adding to Toolbar
To add the item as a toolbar item within account, add the following segment to the ISVConfig and re-import it.




See the attached code for the full implementation. Special thanks to Ryan McCormick and Yaniv Arditi for their help.

-Andrew


This release is provided "AS IS" and contains no warranty and confers no rights.

Saturday, February 21, 2009

CRM 4.0 Registration Problems with On Disk

I was working to install an workflow assembly. I was using the disk option, so I could debug the code. I build the code from the same server that I was installing from. The code that I was building was originally developed on another VPC. The VPC had a CRM installation the was upgraded from 3.0. This VPC was a fresh CRM 4.0 installation.

So, I navigated to the directory and grabbed it. I specified to go to disk and clicked "Register Selected Plugin."



The error returned is:
Unhandled Exception: System.Web.Services.Protocols.SoapException: Server was unable to process request.
Detail: 0x80044191
Assembly can not be loaded from C:\Program Files\Microsoft Dynamics CRM\server\bin\assembly\WorkflowMerge.dll.



When trying to do an install to database it went in fine. However, registering from disk wouldn't go. It ends up that the file needs to be stored in that exact directory or it returns the error. I didn't realize that it wasn't storing it in the right directory because of differences between environments.

Default location for storing assemblies assemblies on CRM 4.0 when upgraded from 3.0:
  • C:\Program Files\Microsoft Dynamics CRM Server\Server\bin\assembly\
Default location for storing assemblies assemblies on CRM 4.0 when a fresh CRM 4.0 installation:
  • C:\Program Files\Microsoft Dynamics CRM\Server\bin\assembly\.