Pages

Monday, November 29, 2010

Renaming Buttons and MenuItems

While working on a recent thread in the CRM Developer forum, I discovered that buttons and menu items throughout CRM have a particular and easily adjustable HTML structure.  Because the labels for these elements are generally statically defined for non-custom actions, sometimes it’s desirable to change them.

While there may be an esoteric method of performing this with language-packs and/or translation code files, I’ve cooked up a Javascript method that allows for this process to be performed wherever custom Javascript can be deployed. 

As with the code used to locate buttons to hide, we search for the “title” attribute of the element.  This is because the “id” attribute is dynamic and inconstant between page-loads.  The following function sufficiently implements the relabeling for both “Button” and “MenuItem” elements:

function RenameTitledButtons(targetElement, origTitle, newName, newTitle) {
  var liElements = targetElement.getElementsByTagName('li');

  for (var i=0; i < liElements.length; i++) {
    if (liElements[i].getAttribute('title').substr(0, origTitle.length) == origTitle) {
      var labelSpan = liElements[i].childNodes[0];

      for (var j=0; j < labelSpan.childNodes[0].childNodes.length; j++) {
        if (labelSpan.childNodes[0].childNodes[j].nodeName == "SPAN") {
          var labelTextSpan = labelSpan.childNodes[0].childNodes[j];

          labelTextSpan.innerHTML = newName;
          break;
        }
      }

      liElements[i].title = newTitle;
      break;
    }
  }
}

To use the function, here are the parameters:

targetElement:  The HTML DOM element containing the items with the label to adjust; document is an acceptable value for buttons within the current form, however a variation of this script is used to target labels in associated views.  (See below)

origTitle:  A string containing any portion (from the first character) of the “title” attribute of the labeled button/menu item.  E.g. the “New XXX” button in an associated view has the title “Add a new XXX to this record”; it’s perfectly acceptable to pass the value “Add a new” into this parameter for this button.

newName:  The new name you wish to provide to the button/menu item.  Whatever is passed to this parameter will become the label.

newTitle:  Whatever is passed to this parameter will replace the contents of the “title” attribute, which will allow you to change the text seen when the mouse hovers over the element.

Now, for a wrapper to this function that works with views contained in Iframes, I’ve developed the following function:

function RenameViewButtons(Iframe, origTitle, newName, newTitle) {
  function RenameViewButtonsByContext() {
    if (Iframe.readyState == 'complete') {
      var iFrame = frames[window.event.srcElement.id];
      RenameTitledButtons(iFrame.document, origTitle, newName, newTitle);
    }
  }

  Iframe.attachEvent("onreadystatechange", RenameViewButtonsByContext);
}

With both functions placed in your code, you can call the new wrapper-method with one parameter difference:

Iframe:  The Iframe element containing the view.  This can be an “IFRAME” control on the form, or that of an associated view.

CAVEAT:  You must call this code before the src attribute of the Iframe is updated.  It’s designed to function by attaching to the “onreadystatechange” event of the Iframe, and executing when the readyState finally changes to “complete”.  If you must perform the renaming afterward, simply use the first function and pass the document element of the Iframe into the first parameter.

For working with naturally navigable associated views, we borrow the form of another code wrapper from my “Improved Code for Hiding Buttons” post:

function RenameAssociatedViewButtons(loadAreaId, origTitle, newName, newTitle){
  function RenameViewButtonsByContext() {
    RenameViewButtons(document.getElementById(loadAreaId + 'Frame'), origTitle, newName, newTitle);
  }
  
  AttachActionToLoadArea(loadAreaId, RenameViewButtonsByContext);
}

Where loadAreaId is the string passed to the loadArea() function by the <A> element’s onclick attribute.  This is discoverable by using the Developer Toolbar in IE8 by pressing [F12] and using the “Select element by click” feature to view the HTML of the navigation link.

That wrapper function, however, requires this bit of utility code:

function AttachActionToLoadArea(loadAreaId, navigationHandler) {
  var navElement = document.getElementById('nav_' + loadAreaId); 

  // if at first you don't succeed...
  if (navElement == null) {
   navElement = document.getElementById('nav' + loadAreaId);
  }

  if (navElement != null)  {    
    navElement.attachEvent("onclick", navigationHandler);
  }
}

The utility code is a reusable segment that attaches any function to the “onclick” event of the navigation link.

Improved Code for Hiding Buttons and MenuItems

[UPDATE 12/2/10: This code no longer uses the display style attribute, which would become undone after certain window operations.  Instead, it uses the removeChild() method.  To make up for the arbitrary destruction of the element, I’ve included instructions and mechanisms at the bottom of this post that will allow you to replace the element to its previous location.]

I’ve often directed people that have inquired about the process of concealing CRM buttons and menu-items to Dave Hawes’ blog.  His blog is the most popular resource for this feature, and I’ve personally contributed to the code for it several times (initially by refactoring the code to accept an array of element titles for concealment).

However, most of the changes I have recommended to Dave through the comments on that blog posting have not appeared in the principle post or in any subsequent post.  So, I thought that I would post the code that has since evolved into the set of functions I use today.

The first function allows you to hide buttons/menu-items anywhere in CRM where the code can run:

function HideTitledButtons(targetElement, buttonTitles) {
  var liElements = targetElement.getElementsByTagName('li');
  var removedButtons = [];

  for (var j = 0; j < buttonTitles.length; j++) {
    for (var i = 0; i < liElements.length; i++) {
      if (liElements[i].getAttribute('title').substr(0, buttonTitles[j].length) == buttonTitles[j]) {
        removedButtons.push({
          title: liElements[i].getAttribute('title'),
          element: liElements[i],
          parent: liElements[i].parentNode,
          sibling: liElements[i].nextSibling
          });
        liElements[i].parentNode.removeChild(liElements[i]);
        break;
      }
    }
  }
  
  return removedButtons;
}

To use the function, here are the parameters:

targetElement:  The HTML DOM element containing the items to hide; document is an acceptable value for buttons within the current form, however a variation of this script is used to target items in the form’s views.  (See below)

buttonTitles:  An array of titles for the buttons to hide.  Each array element is a string containing any portion (from the first character) of the “title” attribute of the labeled button/menu item.  E.g. the “New XXX” button in an associated view has the title “Add a new XXX to this record”; it’s perfectly acceptable to use the value “Add a new” as one element of this array.

This function has been updated to return an array of objects, each element of which contains the following members:

  • title:  The full title attribute of the removed button/menu item element
  • element:  The button/menu item element removed from the DOM
  • parent:  The parent DOM element to the element
  • sibling:  The nextSibling of the element

Now, for a wrapper to this function that works with views contained in Iframes, I’ve developed the following function:

function HideViewButtons(Iframe, buttonTitles, outHiddenButtons) {
  function HideViewButtonsByContext() {
    if (Iframe.readyState == 'complete') {
      var iFrame = frames[window.event.srcElement.id];
      
      var hiddenButtonsReturn = HideTitledButtons(iFrame.document, buttonTitles);
      
      if (typeof outHiddenButtons != "undefined" && outHiddenButtons != null) {
        for (hiddenButtonsReturnIndex in hiddenButtonsReturn) {
          outHiddenButtons.push(hiddenButtonsReturn[hiddenButtonsReturnIndex]);
        }
      }
    }
  }
  
  Iframe.attachEvent("onreadystatechange", HideViewButtonsByContext);
}

With both functions placed in the code, you can call the new wrapper-method with the following parameters:

Iframe:  The Iframe element containing the view.  This can be an “IFRAME” control on the form, or that of an associated view.

buttonTitles:  An array of titles for the buttons to hide.  Each array element is a string containing any portion (from the first character) of the “title” attribute of the labeled button/menu item.  E.g. the “New XXX” button in an associated view has the title “Add a new XXX to this record”; it’s perfectly acceptable to use the value “Add a new” as one element of this array.

outHiddenButtons (Optional):  If you choose to use it, you must pass an array to this parameter.  The referenced array will be loaded with elements from the return of the call to HideTitledButtons().

CAVEAT:  You must call this code before the src attribute of the Iframe is updated.  It’s designed to function by attaching to the “onreadystatechange” event of the Iframe, and executing when the readyState finally changes to “complete”.  If you must perform the hiding afterward, simply use the first function and pass the document element of the Iframe into the first parameter.

For working with naturally navigable associated views, we can use a wrapper to the previous wrapper-method:

function HideAssociatedViewButtons(loadAreaId, buttonTitles, outHiddenButtons){
  function HideViewButtonsByContext() {
    HideViewButtons(document.getElementById(loadAreaId + 'Frame'), buttonTitles, 
      typeof outHiddenButtons != "undefined" ? outHiddenButtons : null);
  }
  
  AttachActionToLoadArea(loadAreaId, HideViewButtonsByContext);
}

Where loadAreaId is the string passed to the loadArea() function by the <A> element’s onclick attribute.  This is discoverable by using the Developer Toolbar in IE8 by pressing [F12] and using the “Select element by click” feature to view the HTML of the navigation link.

outHiddenButtons is an optional parameter for this wrapper function as well, and is passed into the HideViewButtons() method as-is.

This wrapper function, however, requires this bit of utility code:

function AttachActionToLoadArea(loadAreaId, navigationHandler) {
  var navElement = document.getElementById('nav_' + loadAreaId); 

  // if at first you don't succeed...
  if (navElement == null) {
   navElement = document.getElementById('nav' + loadAreaId);
  }

  if (navElement != null)  {    
    navElement.attachEvent("onclick", navigationHandler);
  }
}

(The nifty thing about the AttachActionToLoadArea function is its ability to use the loadAreaId above to discover and attach any function as a handler to the “onclick” event of the targeted navigation link.)

As you can see, I’ve isolated functionality into separate functions that can be circumstantially used as the situation requires.  However, there is much more that could be done to unify the code in this post with the sister “Renaming Buttons and MenuItems” post to make a common, single point of interface for all Button and MenuItem modification.

Restoring Hidden Buttons and MenuItems

So, you’ve used the code above, and now you want to dynamically restore a button that was hidden.  How you go about this depends on how the element was removed in the first place:

Hidden with the HideTitledButtons() method:

// First, capture the return of the HideTitledButtons function that the element is
// saved in the variable hiddenButtons
var hiddenButtons = HideTitledButtons(document, "Some Button Title");

// To restore it, I can use a contextual reference from an array item of hiddenButtons
// to the parent of a hidden element so that I can call it's insertBefore() method.
// When calling that method, I'll pass two members from the array item: element and sibling
hiddenButtons[0].parent.insertBefore(hiddenButtons[0].element, hiddenButtons[0].sibling);

Hidden with either HideViewButtons() or HideAssociatedViewButtons():

// In order to capture the return from the underlying HideTitledButtons function,
// which executes asynchronously, we need a static recipient to pass into the
// outHiddenButtons parameter; using a singleton for this is ideal if you want
// to avoid having to pass the reference of the recipient into various script scopes.
// Whichever you decide, initialize it as an empty array:
HiddenButtons = [];

// Passing it into HideViewButtons:
HideViewButtons(crmForm.all.IFRAME_Something, ["Add a new", "Add existing"], HiddenButtons);

// Passing it into HideAssociatedViewButtons:
HideAssociatedViewButtons("areaSomething", ["Add a new", "Add existing"], HiddenButtons);

Restoring elements from HiddenButtons is identical to the process for hiddenButtons in the HideTitledButtons() example.

You can pass the same singleton/variable (in this example: HiddenButtons) into all calls of HideViewButtons() or HideAssociatedViewButtons(), and you’ll have a single array that holds every removed button/menu item element.  That may be confusing, however, when trying to restore specific elements (though you can probe the title member of each array item to determine if you have the correct one).  Ideally, you’ll want a 1:1 ratio of outHiddenButtons recipients to views.

Thursday, November 4, 2010

Delete This Record! A Custom Workflow Activity

Some time ago, I created a handy bit of Workflow code that issues a “Delete” for the record in the Workflow’s context.  Sometimes I use it in Workflows that eliminate useless records (determined by conditions), other times I use it to drive dynamic record sets.  Either way, it saves a lot of time over creating and managing Bulk Deletion jobs for records that just shouldn’t exist any longer, or that should no longer exist under dynamic circumstances.

So, without further ado:

using System;
using System.Collections;
using System.Workflow.Activities;
using System.Workflow.ComponentModel;
using Microsoft.Crm.Sdk;
using Microsoft.Crm.Sdk.Query;
using Microsoft.Crm.SdkTypeProxy;
using Microsoft.Crm.Workflow;

namespace CrmWorkflows
{
    /// <summary>
    /// Defines the workflow action, and places it in a container directory
    /// </summary>
    [CrmWorkflowActivity("Delete This Record", "General Utilities")]
    public class DeleteRecord : Activity
    {
        #region Activity Members

        /// <summary>
        /// Overridden Execute() function to provide functionality to the workflow.
        /// </summary>
        /// <param name="executionContext">Execution context of the Workflow</param>
        /// <returns></returns>
        protected override ActivityExecutionStatus Execute(ActivityExecutionContext executionContext)
        {
            IContextService contextService = (IContextService)executionContext.GetService(typeof(IContextService));
            IWorkflowContext context = contextService.Context;

            ICrmService crmService = context.CreateCrmService();

            crmService.Delete(context.PrimaryEntityName, context.PrimaryEntityId);

            return ActivityExecutionStatus.Closed;
        }

        #endregion
    }
}