Sunday, September 27, 2009

CRM JavaScript Web Service Helper

[UPDATE - Jan 21, 2010] I have released an updated version of script under the name of CRM Web Service Toolkit, please check my latest blog for the details.

In CRM's form customization, we often need to implement business logics based on the information that is not available right away from the crmForm object. In this case, it's common practice to use CRM Web Service to retrieve this type of information from CRM database. This is what I have been doing extensively in my current CRM project, so I spent some of my weekend time to create a re-usable JavaScript CRM Web Service Helper, which you can copy and use.
CrmServiceHelper = function()
{
    /**
     * CrmServiceHelper 1.0
     *
     * @author Daniel Cai
     * @website http://danielcai.blogspot.com/
     * @copyright Daniel Cai
     * @license Microsoft Public License (Ms-PL), http://www.opensource.org/licenses/ms-pl.html
     *
     * This release is provided "AS IS" and contains no warranty or whatsoever.
     *
     * Date: Sep 27 2009
     */

    // Private members
    var DoRequest = function(soapBody, requestType)
    {
        //Wrap the Soap Body in a soap:Envelope.
        var soapXml =
                "<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/' " +
                "xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' " +
                "xmlns:xsd='http://www.w3.org/2001/XMLSchema'>" +
                GenerateAuthenticationHeader() +
                "<soap:Body><" + requestType + " xmlns='http://schemas.microsoft.com/crm/2007/WebServices'>" +
                soapBody + "</" + requestType + ">" +
                "</soap:Body>" +
                "</soap:Envelope>";

        var xmlhttp = new ActiveXObject("Msxml2.XMLHTTP");
        xmlhttp.open("POST", "/MSCRMServices/2007/crmservice.asmx", false);
        xmlhttp.setRequestHeader("Content-Type", "text/xml; charset=utf-8");
        xmlhttp.setRequestHeader("SOAPAction", "http://schemas.microsoft.com/crm/2007/WebServices/" + requestType);

        //Send the XMLHTTP object.
        xmlhttp.send(soapXml);

        var resultXml = xmlhttp.responseXML;

        if (resultXml === null || resultXml.xml === null || resultXml.xml === "")
        {
            if (xmlhttp.responseText !== null && xmlhttp.responseText !== "")
            {
                throw new Error(xmlhttp.responseText);
            }
            else
            {
                throw new Error("No response received from the server. ");
            }
        }

        // Report the error if occurred
        var error = resultXml.selectSingleNode("//error");
        var faultString = resultXml.selectSingleNode("//faultstring");

        if (error !== null || faultString !== null)
        {
            throw new Error(error !== null ? resultXml.selectSingleNode('//description').nodeTypedValue : faultString.text);
        }

        return resultXml;
    };

    var BusinessEntity = function(sName)
    {
        this.name = sName;
        this.attributes = new Object();
    };

    var DataType = {
        String : "string",
        Boolean : "boolean",
        Int : "int",
        Float : "float",
        DateTime : "datetime"
    };

    // Public members
    return {
        BusinessEntity : BusinessEntity,

        DataType : DataType,

        DoRequest : DoRequest,

        Retrieve : function(entityName, id, columns)
        {
            var attributes = "";
            if (typeof attributes !== "undefined")
            {
                for (var i = 0; i < columns.length; i++)
                {
                    attributes += "<q1:Attribute>" + columns[i] + "</q1:Attribute>";
                }
            }

            var msgBody =
                    "<entityName>" + entityName + "</entityName>" +
                    "<id>" + id + "</id>" +
                    "<columnSet xmlns:q1='http://schemas.microsoft.com/crm/2006/Query' xsi:type='q1:ColumnSet'>" +
                    "<q1:Attributes>" +
                    attributes +
                    "</q1:Attributes>" +
                    "</columnSet>";

            var resultXml = DoRequest(msgBody, "Retrieve");
            var xmlDoc = new ActiveXObject("Microsoft.XMLDOM");
            xmlDoc.async = false;
            xmlDoc.loadXML(resultXml.xml);

            var retrieveResult = xmlDoc.selectSingleNode("//RetrieveResult");
            if (retrieveResult === null)
            {
                throw new Error("Invalid result returned from server. ");
            }

            var resultNodes = retrieveResult.childNodes;
            var returnEntity = new BusinessEntity();
            for (var i = 0; i < resultNodes.length; i++)
            {
                var fieldNode = resultNodes[i];
                var field = {};
                field["value"] = fieldNode.text;

                for (var j = 0; j < fieldNode.attributes.length; j++)
                {
                    field[fieldNode.attributes[j].nodeName] = fieldNode.attributes[j].nodeValue;
                }

                returnEntity.attributes[fieldNode.baseName] = field;
            }

            return returnEntity;
        },

        Fetch : function(xml)
        {
            var msgBody = "<fetchXml>" + _HtmlEncode(xml) + "</fetchXml>";

            var resultXml = DoRequest(msgBody, "Fetch");
            var xmlDoc = new ActiveXObject("Microsoft.XMLDOM");
            xmlDoc.async = false;
            xmlDoc.loadXML(resultXml.xml);

            var fetchResult = xmlDoc.selectSingleNode("//FetchResult");
            if (fetchResult === null)
            {
                throw new Error("Invalid result returned from server. ");
            }
            xmlDoc.loadXML(fetchResult.childNodes[0].nodeValue);

            var resultNodes = xmlDoc.selectNodes("/resultset/result");
            var results = [];

            for (var i = 0; i < resultNodes.length; i++)
            {
                var resultEntity = new BusinessEntity();

                for (var j = 0; j < resultNodes[i].childNodes.length; j++)
                {
                    var fieldNode = resultNodes[i].childNodes[j];
                    var field = {};
                    field["value"] = fieldNode.text;

                    for (var k = 0; k < fieldNode.attributes.length; k++)
                    {
                        field[fieldNode.attributes[k].nodeName] = fieldNode.attributes[k].nodeValue;
                    }

                    resultEntity.attributes[fieldNode.baseName] = field;
                }

                results[i] = resultEntity;
            }

            return results;
        },

        Execute : function(request)
        {
            var msgBody = request;

            var resultXml = DoRequest(msgBody, "Execute");
            var xmlDoc = new ActiveXObject("Microsoft.XMLDOM");
            xmlDoc.async = false;
            xmlDoc.loadXML(resultXml.xml);
            return xmlDoc;
        },

        ParseValue : function(businessEntity, crmProperty, type, crmPropertyAttribute)
        {
            if (businessEntity === null || typeof crmProperty === "undefined" || !businessEntity.attributes.hasOwnProperty(crmProperty))
            {
                return null;
            }

            var value = (typeof crmPropertyAttribute !== "undefined")
                    ? businessEntity.attributes[crmProperty][crmPropertyAttribute]
                    : businessEntity.attributes[crmProperty].value;

            switch (type)
                    {
                case DataType.Boolean:
                    return (value !== null) ? (value === "1") : false;
                case DataType.Float:
                    return (value !== null) ? parseFloat(value) : 0;
                case DataType.Int:
                    return (value !== null) ? parseInt(value) : 0;
                case DataType.DateTime:
                    return (value !== null) ? ParseDate(value) : null;
                case DataType.String:
                    return (value !== null) ? value : "";
                default:
                    return (value !== null) ? value : null;
            }

            return null;
        }
    };
}();

The following are a few scenarios that your might find the CRM Web Service Helper userful.
  1. Fetch a list of records or one record, in which case you can use CrmServiceHelper.DoFetchXmlRequest().
    var fetchXml = 
    '<fetch mapping="logical">' +
       '<entity name="account">' +
          '<attribute name="name" />' +
          '<attribute name="primarycontactid" />' +
          '<filter>' +
             '<condition attribute="accountid" operator="eq" value="' + crmForm.all.accountid.DataValue[0].id + '" />' +
          '</filter>' +
       '</entity>' +
    '</fetch>';  
     
    var fetchResult = CrmServiceHelper.Fetch(fetchXml);
    alert(CrmServiceHelper.ParseValue(fetchResult[0], 'name'));
    

  2. Retrieve one record.
    var retrieveResult = CrmServiceHelper.Retrieve('account', crmForm.all.accountid.DataValue[0].id, ['accountid', 'name']);
    alert(CrmServiceHelper.ParseValue(retrieveResult, 'name'));

  3. Execute a request.
    function GetCurrentUserId()
    {
       var request = "<Request xsi:type='WhoAmIRequest' />";
       var xmlDoc = CrmServiceHelper.Execute(request);
     
       var userid = xmlDoc.getElementsByTagName("UserId")[0].childNodes[0].nodeValue;
       return userid;
    }

Beyond the above example, you can use CrmServiceHelper.DoRequest() function to make any other CRM service calls including Create, Update, Delete, etc.

A few final notes about Web Service Helper:
  1. CrmServiceHelper is designed as a container object to provide all necessary interfaces to interact with CRM Web Service through JavaScript. By this approach, we don't pollute JavaScript global namespace with a lot of objects.
  2. All functions in CrmServiceHelper throws an error when exception happens, it's your responsibility to handle this type of exception. The common practice is using try/catch block, but it's totally up to you. If you don't use try/catch block, CRM platform will catch it, and give the user an alert warning window.
  3. You should probably consider saving the above script to a file such as CrmServiceHelper.js, and upload the file to your CRM's ISV folder, then use Henry Cordes' load_script() function to load and consume the service helper in the form's onload event.
  4. When you use CrmServiceHelper.GetValueFromFetchResult() function to parse the fetched result, please make sure to specify the datatype correctly.
  5. FetchXML is extremely flexible, it can do almost anything that you may want (except some native SQL functions such as SOUNDEX, etc.) in CRM, FetchXML is usually my first choice when I need to retrieve more than one record from CRM using JavaScript (In C#, you might want to use Query Expression due to the syntax friendship in IDE environment). You might want to consider using Stunnware Tools to help you create FetchXML in a more productive way.

[UPDATE - Jan 21, 2010] I have released an updated version of script under the name of CRM Web Service Toolkit, please check my latest blog for the details.

MSCRM 4.0 Filtered Lookup

Filtered lookup is a frequently requested features in CRM projects, however there is no out-of-box solution from CRM 4 platform itself.

George Doubinski previously provided a solution for filtered lookup in CRM4, but it requires you to modify CRM application files, which I usually try to avoid unless absolutely no choice, as it will cause deployment and maintenance issue.

Jim Wang also provided a plug-in based solution (his example is basically for N:N relationship, but the concept pretty much applies to typical 1:N lookup as well), which is a good solution if you want an application-wide filtering lookup for a particular entity. By application-wide, I mean you are using the same filtering criteria everywhere in your application. If we put it the other way, you don't have the flexibility of using different filtering lookup for different forms when the plug-in based approach is used. One more issue to be aware is that the plug-in approach is basically hijacking all Execute messages, which could introduce some performance overhead to CRM platform, as Execute message is such a popular message on CRM platform.

After tweaking around George's solution, I have coined a slightly better solution based on George's approach. The following are the steps that I have taken.
  1. Create a subfolder called CustomLookup under your CRM's ISV folder.
  2. Copy <crmweb folder>\_controls\lookup\lookupsingle.aspx page to your ISV\CustomLookup folder which you have just created.
  3. Add the following script tag to <crmweb folder>\ISV\CustomLookup\lookupsingle.aspx (Please thank George for his code, this is basically a copy from his blog).   
    <%--
    ********************************** BEGIN: Custom Changes **********************************
    This page is a copy of <crmweb folder>\_controls\lookup\lookupsingle.aspx file 
    with the following custom change. 
    --%>
    <script runat="server"> 
    protected override void OnLoad( EventArgs e ) 
    { 
          base.OnLoad(e); 
          crmGrid.PreRender += new EventHandler( crmgrid_PreRender ); 
    } 
    
    void crmgrid_PreRender( object sender , EventArgs e ) 
    {
        // As we don't want to break any other lookups, ensure that we use workaround only if
        // search parameter set to fetch xml.
        if (crmGrid.Parameters["search"] != null && crmGrid.Parameters["search"].StartsWith("<fetch")) 
        { 
            crmGrid.Parameters.Add("fetchxml", crmGrid.Parameters["search"]);  
    
            // searchvalue needs to be removed as it's typically set to a wildcard '*' 
            crmGrid.Parameters.Remove("searchvalue");  
    
            // Icing on a cake - ensure that user cannot create new new record from the lookup window
            this._showNewButton = false; 
        } 
    }
    </script> 
    <%--
    ********************************** END: Custom Changes **********************************
    --%>
  4. Add the following script to your form’s onload event
    (function ReplaceCrmLookup() {
        window.oldOpenStdDlg = window.openStdDlg;
        window.openStdDlg = function() {
            arguments[0] = arguments[0].replace(/\/_controls\/lookup\/lookupsingle.aspx/i, '/ISV/CustomLookup/lookupsingle.aspx');
            return oldOpenStdDlg.apply(this, arguments);
        };
    })();

  5. Then you are ready to add filtering criteria to your lookup field. For instance, there is a business requirement that you need to do filtering for the Primary Contact field of Account form, so that when the Primary Contact lookup is clicked, the lookup window will only show the contact records that are associated to the current account. Your code will be like this:
    (function AddFilterToPrimaryContactEntity() {
        var lookup = crmForm.all.primarycontactid; 
        if(crmForm.ObjectId == null) { 
            // Disable lookup for new account record as there can be no contacts 
            field.Disabled = true; 
        } 
        else { 
            // Ensure that search box is not visible in a lookup dialog
            lookup.lookupbrowse = 1; 
            
            // Pass fetch xml through search value parameter 
            lookup.AddParam('search', 
    '<fetch mapping="logical">' +
       '<entity name="contact">' +   
          '<filter>' +
             '<condition attribute="parentcustomerid" operator="eq" value="' + crmForm.ObjectId + '" />' +
          '</filter>' + 
       '</entity>' +
    '</fetch>'); 
        }
    })();

  6. Save your form and publish your entity, you are good to go now.
Basically this approach opens up a lot of possibilities when you want to replace a CRM built-in page with your own ones. You can add similar filtering functions to a N:N lookup when "Add Existing ..." button is clicked ([UPDATE - Feb 8, 2011] I implemented this function since there was a request for this feature in CRM Development Forum).

Note that when you implement a filtered lookup field using the script here, you need to check "Turn off automatic resolutions in field" option in the CRM lookup field's property window, otherwise the lookup dialog page will throw an error when the user has typed in something in the lookup textbox. The option is unchecked by default.

Will this make it a supported solution? Not really. But your future rollup update should at least not overwrite your code.

You may wonder why we should use this approach instead of George's organic one? The reason is, using this technique, you don't mess with CRM system files, there are a couple of benefits associated with this:

  1. Your modified CRM system file (lookupsingle.aspx in our case) is stored in the ISV folder, your code will have much better chance to survive when you apply new Rollup Update to your CRM server. ISV folder is simply your empire, Microsoft Dynamics CRM server rollup update is supposed to never touch the files in the ISV folder. Lets's put it the other way, due to the fact that CRM Rollup is always a cumulative update package that includes all the fixes since the RTM version (the very first version of CRM 4.0), it could update and overwrite any files that have ever been changed since Rollup 1, which means your modified lookupsingle.aspx file could be overwritten by every single new Rollup Update you are planning to apply to your CRM server, if the organic approach is used. 
  2. The modified CRM system file is isolated in your ISV folder, it will make thing a lot easier to maintain the application. Also it will be easier to create your own installation package for your custom application. 

Credit goes to George Doubinski. Happy CRM'ing!

Wednesday, September 16, 2009

Detect CRM4 Grid's Refresh Event

In my previous blog, I have documented the script to load associated view in an IFrame. Sometime, there might be circumstances that we want to manipulate the data in the grid, which is achievable through the crmGrid object (in the IFrame’s document) by listening to its OnRefresh event. The crmGrid has exposed a few events and a few dozens of methods which we can use depending on the scenario.

The following is the code that I am using in my project to listen to the gird’s OnRefresh event. In my case, I need to make one field as required on the CRM form when certain number of records have been detected for the associated entity.

function DetectGridRecordChange() {

    var myframe = crmForm.all.IFRAME_MyFrameId;

    myframe.onreadystatechange = function() {
        if (myframe.readyState === "complete")
        {
            var frameDoc = myframe.contentWindow.document;

            frameDoc.all['crmGrid'].attachEvent("OnRefresh", function()
            {
                // Do something
                alert("CRM Grid refreshed! ");
            });
        }
    }
}

MSCRM 4.0: Show Entity’s Associated View in IFrame

While Jim Wang, Matt Wittemann, and many others have posted the code to load an associated view in an iframe on a CRM form, I am trying to give a self-contained JavaScript function to handle this requirement, with one tiny enhancement.

The following is the script that I have evolved based on the scripts that I have just mentioned.

/**
 * Load an associated view into an IFrame while hiding it from LHS navigation menu. 
 * @author Daniel Cai, http://danielcai.blogspot.com/
 * 
 * Parameters:
 * @param iframe:      The IFrame's object, e.g. crmForm.all.IFrame_Employer_Address
 * @param navItemId:   LHS navigator's HTML element ID of the associated view.
                       It usually starts with "nav".
 */
function loadAssociatedViewInIFrame (iframe, navItemId)
{
    var clickActionPattern =  /loadArea\(['"]{1}([A-Za-z0-9_]+)['"]{1}(, ?['"]\\x26roleOrd\\x3d(\d)['"])*\).*/;   

    var getFrameSrc = function (areaId, roleOrd)
    {
        var url = "areas.aspx?oId=" + encodeURI(crmForm.ObjectId);
        url += "&oType=" + crmForm.ObjectTypeCode;
        url += "&security=" + crmFormSubmit.crmFormSubmitSecurity.value;
        url += "&tabSet=" + areaId;
        url += (!roleOrd) ?  "" : "&roleOrd=" + roleOrd;

        return url;
    };

    var onReadyStateChange = function() {
        if (iframe.readyState === 'complete') {
            var frameDoc = iframe.contentWindow.document;

            // Remove the padding space around the iframe
            frameDoc.body.scroll = "no";
            frameDoc.body.childNodes[0].rows[0].cells[0].style.padding = "0px";

            iframe.detachEvent('onreadystatechange', onReadyStateChange);
        }
    };

    (function init() {
        if (!crmForm.ObjectId) return;

        var navItem = document.getElementById(navItemId);
        if (!navItem) return;

        var clickAction = navItem.getAttributeNode('onclick').nodeValue;  
        if (!clickAction || !clickActionPattern.test(clickAction))  
            return;  
 
        var areaId = clickAction.replace(clickActionPattern, '$1');  
        var roleOrd = clickAction.replace(clickActionPattern, '$3');

        navItem.style.display = 'none';

        iframe.src = getFrameSrc(areaId, roleOrd);
        iframe.allowTransparency = true; // Get rid of the white area around the IFrame
        iframe.attachEvent('onreadystatechange', onReadyStateChange);
    })();
};
The tiny enhancement I mentioned was, the script tries to make the IFrame transparent so you won’t see a white-color area around the IFrame.


To use the above function, you may make a call like the following snippet:
loadAssociatedViewInIFrame(crmForm.all.IFRAME_Accounts, "navnew_account_new_project");
In the above snippet, IFRAME_Accounts is the iframe that I am using to load the associated view, while navnew_account_new_project is the ID of the left navigation item which you can click to navigate to the associated CRM view.

[Update - Mar 27, 2010] I updated the code with some little enhancements while I was writing another blog post about removing "Add Existing" button from the associated view.

[Update - Feb 08, 2010] I updated the code a little bit, while I was writing another blog post today, so it now requires only two parameters instead of the previous three. I know it's a little silly to update a blog post that is almost one and half years old. My intention is to have a script that either looks better or works better if I see that kind of opportunity. ;-)

Error when applying CRM4 Update Rollup

I was recently trying to apply the latest CRM 4 Update Rollup 6 to my VPC, and I got the following error message:
Action Microsoft.Crm.UpdateWrapper.MspInstallAction failed.
This patch package could not be opened. Verify that the patch package exists and that you can access it, or contact the application vendor to verify that this is a valid Windows Installer patch package.
CrmUpdateRollupFail
After I spent a few hours tweaking Windows registry with no success, I fixed the problem by using the following procedures:
  1. Download Windows Installer Cleanup Utility from http://support.microsoft.com/kb/290301, and install it.
  2. Run Windows Installer Cleanup Utility, choose Microsoft Dynamics CRM Server [4.0.7333.3], and remove it.
    UninstallCRM4
    Note: Windows Installer Clean Up Utility would not actually physically remove any files from your CRM server installation folder, nor your database files. It basically removes the registry entries related to your previous installation. It’s pretty safe to run this program.
  3. Re-run CRM installation program.
    • In "Specify Deployment Options" window, type the SQL Server’s name in the textbox and then choose "Connect to an existing deployment".
      ChooseCrmDeploymentOptions
    • In "System requirements" window, you will see a warning saying that "Setup might overwrite the existing files". It’s time to consider making a backup for all ISV files if you haven’t done so yet. After you have done the backup, you can click "Next" to finish the installation.
      CrmSystemRequirements
  4. After you finish the installation and reboot your CRM server, you should be good to apply the update rollup now.
The entire process shouldn't take you more than 30 minutes.

Tuesday, September 15, 2009

Error: Invalid User Authorization. The user authentication passed to the platform is not valid

In one of our CRM projects, a large number of users recently started to receive the following error when they try to login to the CRM system.

Invalid User Authorization. The user authentication passed to the platform is not valid.

InvalidUserAuthorization
After a quick search, I found that Microsoft has provided some sort of solution at http://support.microsoft.com/kb/860612. The knowledge base page has provided some good explanation about the nature of the issue, but the solution didn't make much sense to our situation.

My sense with this issue is that the CRM users were somehow out of sync with Active Directory accounts. Two solutions came to my mind quickly:
  • Re-import the organization, and let the CRM Deployment Manager provision all CRM users using latest AD user IDs.

    or
     
  • Open each user in CRM, and change its Domain Logon Name to a temporary one (I used MyDomain\Guest as shown below, save it. And then change back to the user’s actual MyDomain\Firstname.Lastname, and save it again. The user will be able to login without any error, as CRM is actually re-mapping the CRM user to the correct AD user during the changes. Regarding the temporary AD user, it's not my intention to suggest that you use GUEST account as the temporary AD user due to obvious security reason. I just knew this account was there, so I didn’t have to request a new temporary AD account through the infrastructure team. You may want to use a different account which is more appropriate.
InvalidUserAuthorization-Solution
Either solution worked for us, because both of them are actually re-provisioning CRM user records. The first approach does it through some sort of batch process when the organization is imported, while the 2nd approach does it on an individual user record basis. The good thing about 2nd approach is that you don't need to shut down or disconnect the system for re-importing the organization, you can recover the invalid users while other valid CRM users still have access. But if you have a large number of invalid CRM users, you might find the first approach a little bit easier.

[Update - Apr 11, 2010] 
Re-importing CRM organization (the first approach) does have a number of complications associated with it. Make sure to use it with extra caution. Ensure that you have full backup of both MSCRM_CONFIG and CRM organization databases before you actually perform the re-importing.
[End of Update - Apr 11, 2010] 

You may be wondering why ever a CRM user account could be out-of-sync with the Active Directory account. The most common case is, the AD user account has been deleted and recreated using the same name.

The reason is, when a CRM user account is created, it's linked to an AD account, with the Active Directory ID (GUID) of the user account stored in CRM database. If the AD account is deleted, then the CRM user is no longer linked to a valid Active Directory user. Even you have recreated an AD account with the same name, the actual ID of the Active Direcotry user is different, that's why you are seeing the "Invalid User Authorization" error message.

After I have determined the solutions and recovered all CRM users, I was informed by the infrastructure team supporting the application, what they did was, a change request was initiated to have all previous AD users deleted from Active Directory, and they re-generated all the users using an automated script without knowing the impact of such change to CRM application. What matters with regard to the CRM platform is, as I have explained above, after running such script, all CRM users are no longer linked to valid AD users, which was the cause of such authentication error.

Hope this helps if you run into similar error.