Second Life of a Hungarian SharePoint Geek

April 2, 2014

How to Find the Right Tool in SharePoint Central Administration Faster

Probably it’s not just because I’m a developer and not an IT-Pro, but when working with the Central Administration of SharePoint I often ask myself questions like these ones:

Where should I search the incoming e-mail configuration? In the General Application Settings? Or on the System Settings page?

As you know, the items on the Central Administration pages are stored as custom actions and custom action groups in the background. That provides a great opportunity to extend these pages via registering your own custom actions, as well makes it easy to create tools that enumerate the items on the pages, as I illustrated in this post a few years ago. I planned to create a UI extension for the Central Administration already at that time, to make it easier to find the right admin page, but until now I had not fulfilled this plan: to add a simple text input field to the start page of the Central Administration where you can type a word and an autocomplete list would display all of the matching actions.

Let’s see how to achieve that goal!

First I created a console application (SPCustomActionsExtract) that helps us to export the custom actions and groups that we will use in a JavaScript later.

In this project I defined my CustomActionGroup class that should hold the information extracted from the corresponding SPCustomActionGroupElement object. The CustomActionGroup class has the properties we need to extract, and these properties have the very same name as their counterparts in SPCustomActionGroupElement. It is important, since it makes the extraction process via Reflection very simple as we will see it later in the GetCustomActionGroup method. The class is decorated with the DataContract attribute and the properties are decorated with the DataMember attribute, since we wish to serialize the object later.

  1. [DataContract]
  2. internal class CustomActionGroup
  3. {
  4.     [DataMember]
  5.     internal string Id { get; set; }
  6.  
  7.     [DataMember]
  8.     internal string Title { get; set; }
  9.  
  10.     [DataMember]
  11.     internal string ImageUrl { get; set; }
  12.  
  13.     [DataMember]
  14.     internal List<CustomAction> CustomActions { get; set; }
  15. }

We have a similar class called CustomAction that corresponds to the SPCustomActionElement class of SharePoint. In this case, the extraction process is implemented in the GetCustomAction method (see it later).

  1. [DataContract]
  2. internal class CustomAction
  3. {
  4.     [DataMember]
  5.     internal string Title { get; set; }
  6.  
  7.     [DataMember]
  8.     internal string Description { get; set; }
  9.     
  10.     [DataMember]
  11.     internal string GroupId { get; set; }
  12.     
  13.     [DataMember]
  14.     internal string Location { get; set; }
  15.  
  16.     [DataMember]
  17.     internal string UrlAction { get; set; }
  18. }

Since we would like to use the output in JavaScript, serialization into a JSON format seems to be a good idea. I borrowed the code of the JSON serializer from this CodeProject article.

  1. internal static class JsonHelper
  2. {
  3.     public static string JsonSerializer<T>(T t)
  4.     {
  5.         DataContractJsonSerializer ser = new DataContractJsonSerializer(typeof(T));
  6.         MemoryStream ms = new MemoryStream();
  7.         ser.WriteObject(ms, t);
  8.         string jsonString = Encoding.UTF8.GetString(ms.ToArray());
  9.         ms.Close();
  10.         return jsonString;
  11.     }  
  12. }

In the GetCustomActionGroups method we get all of the custom actions for a specific location. The SPCustomActionGroupElement instances are wrapped into CustomActionGroup objects in the GetCustomActionGroup method.

  1. private List<CustomActionGroup> GetCustomActionGroups(SPWeb web, String scope, String location)
  2. {
  3.     List<CustomActionGroup> customActionGroups = new List<CustomActionGroup>();
  4.  
  5.     // hack to get the Microsoft.SharPoint assembly
  6.     Assembly sharePointAssembly = typeof(SPWeb).Assembly;
  7.     // and a reference to the type of the SPElementProvider internal class
  8.     Type spElementProviderType = sharePointAssembly.GetType("Microsoft.SharePoint.SPElementProvider");
  9.  
  10.     ConstructorInfo ci_SPElementProvider = spElementProviderType.GetConstructor(BindingFlags.Public | BindingFlags.Instance,
  11.          null, new Type[0], null);
  12.  
  13.     if (ci_SPElementProvider != null)
  14.     {
  15.         // spElementProvider will be of type internal class
  16.         // Microsoft.SharePoint.SPElementProvider
  17.         // defined in Microsoft.SharePoint assembly
  18.         Object spElementProvider = ci_SPElementProvider.Invoke(null);
  19.  
  20.         if (spElementProvider != null)
  21.         {
  22.             // we call
  23.             // internal List<SPCustomActionGroupElement> QueryForCustomActionGroups(SPWeb web, SPList list, string scope, string location, string groupId)
  24.  
  25.             MethodInfo mi_QueryForCustomActionGroups = spElementProviderType.GetMethod("QueryForCustomActionGroups",
  26.                     BindingFlags.NonPublic | BindingFlags.Instance, null,
  27.                     new Type[] { typeof(SPWeb), typeof(String), typeof(String) }, null
  28.                     );
  29.             if (mi_QueryForCustomActionGroups != null)
  30.             {
  31.                 // result is List<SPCustomActionGroupElement>
  32.                 IEnumerable spCustomActionGroups = (IEnumerable)mi_QueryForCustomActionGroups.Invoke(spElementProvider,
  33.                     new Object[] { web, scope, location });
  34.  
  35.                 customActionGroups = spCustomActionGroups.Cast<Object>().AsQueryable().ToList().ConvertAll(
  36.                     spCag => GetCustomActionGroup(spCag));
  37.             }
  38.         }
  39.     }
  40.  
  41.     return customActionGroups;
  42. }
  43.  
  44. private CustomActionGroup GetCustomActionGroup(object spCustomActionGroup)
  45. {
  46.     CustomActionGroup result = new CustomActionGroup();
  47.  
  48.     Type customActionGroupType = typeof(CustomActionGroup);
  49.     PropertyInfo[] cagPis = customActionGroupType.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance);
  50.  
  51.     // hack to get the Microsoft.SharPoint assembly
  52.     Assembly sharePointAssembly = typeof(SPWeb).Assembly;
  53.     // and a reference to the type of the SPCustomActionGroupElement internal class
  54.     Type spCustomActionGroupElementType = sharePointAssembly.GetType("Microsoft.SharePoint.SPCustomActionGroupElement");
  55.  
  56.     // runtime check the type of the parameter
  57.     if (spCustomActionGroup.GetType() == spCustomActionGroupElementType)
  58.     {
  59.         List<String> propValues = new List<String>();
  60.         cagPis.Where(cagPi => cagPi.PropertyType == typeof(String)).ToList().ForEach(cagPi =>
  61.         {
  62.             string propName = cagPi.Name;
  63.             System.Reflection.PropertyInfo pi = spCustomActionGroupElementType.GetProperty(
  64.                 propName, BindingFlags.Public | BindingFlags.Instance);
  65.             if (pi != null)
  66.             {
  67.                 cagPi.SetValue(result, pi.GetValue(spCustomActionGroup, null), null);
  68.             }
  69.         });
  70.     }
  71.  
  72.     return result;
  73. }

In the GetCustomActions method we get all of the custom actions based on the location and group ID. The SPCustomActionElement instances are wrapped into CustomAction objects in the GetCustomAction method.

  1. private List<CustomAction> GetCustomActions(SPWeb web, SPList list, String scope, String location, String groupId)
  2. {
  3.     List<CustomAction> customActions = new List<CustomAction>();
  4.  
  5.     // hack to get the Microsoft.SharPoint assembly
  6.     Assembly sharePointAssembly = typeof(SPWeb).Assembly;
  7.     // and a reference to the type of the SPElementProvider internal class
  8.     Type spElementProviderType = sharePointAssembly.GetType("Microsoft.SharePoint.SPElementProvider");
  9.  
  10.     ConstructorInfo ci_SPElementProvider = spElementProviderType.GetConstructor(BindingFlags.Public | BindingFlags.Instance,
  11.          null, new Type[0], null);
  12.  
  13.     if (ci_SPElementProvider != null)
  14.     {
  15.         // spElementProvider will be of type internal class
  16.         // Microsoft.SharePoint.SPElementProvider
  17.         // defined in Microsoft.SharePoint assembly
  18.         Object spElementProvider = ci_SPElementProvider.Invoke(null);
  19.  
  20.         if (spElementProvider != null)
  21.         {
  22.             // we call
  23.             // internal List<SPCustomActionElement> QueryForCustomActions(SPWeb web, SPList list, string scope, string location, string groupId)
  24.  
  25.             MethodInfo mi_QueryForCustomActions = spElementProviderType.GetMethod("QueryForCustomActions",
  26.                     BindingFlags.NonPublic | BindingFlags.Instance, null,
  27.                     new Type[] { typeof(SPWeb), typeof(SPList), typeof(String), typeof(String), typeof(String) }, null
  28.                     );
  29.             if (mi_QueryForCustomActions != null)
  30.             {
  31.                 // result is List<SPCustomActionElement>
  32.                 IEnumerable spCustomActions = (IEnumerable)mi_QueryForCustomActions.Invoke(spElementProvider,
  33.                     new Object[] { web, list, scope, location, groupId });
  34.  
  35.                 customActions = spCustomActions.Cast<Object>().AsQueryable().ToList()
  36.                                                                   .ConvertAll(spCa => GetCustomAction(spCa));
  37.             }
  38.         }
  39.     }
  40.  
  41.     return customActions;
  42. }
  43.  
  44. private CustomAction GetCustomAction(object spCustomAction)
  45. {
  46.     CustomAction result = new CustomAction();
  47.     
  48.     Type customActionType = typeof(CustomAction);
  49.     PropertyInfo[] caPis = customActionType.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance);
  50.  
  51.     // hack to get the Microsoft.SharPoint assembly
  52.     Assembly sharePointAssembly = typeof(SPWeb).Assembly;
  53.     // and a reference to the type of the SPCustomActionElement internal class
  54.     Type spCustomActionElementType = sharePointAssembly.GetType("Microsoft.SharePoint.SPCustomActionElement");
  55.  
  56.     // runtime check the type of the parameter
  57.     if (spCustomAction.GetType() == spCustomActionElementType)
  58.     {
  59.         List<String> propValues = new List<String>();
  60.         caPis.ToList().ForEach(caPi =>
  61.         {
  62.             string propName = caPi.Name;
  63.             System.Reflection.PropertyInfo pi = spCustomActionElementType.GetProperty(
  64.                 propName, BindingFlags.Public | BindingFlags.Instance);
  65.             if (pi != null)
  66.             {
  67.                 caPi.SetValue(result, pi.GetValue(spCustomAction, null), null);
  68.             }
  69.         });
  70.     }
  71.  
  72.     return result;
  73. }

The possible values of the locations for the Central Administration are defined in the locations list. You find these values on MSDN. In this case we ignored the start page (location "Microsoft.SharePoint.Administration.Default"), as it contains only duplications of custom actions from other pages.

In the ExtractCustomActions method we iterate through all of these locations, get the custom action groups, and each custom actions in the groups, accumulate them into the allCustomActionGroups list, and finally save the JSON serialized result into a file.

  1. // Central Administration Custom Action Locations
  2. //http://msdn.microsoft.com/en-us/library/bb802730.aspx
  3. List<String> locations = new List<String>() {
  4.                         "Microsoft.SharePoint.Administration.Applications",
  5.                         "Microsoft.SharePoint.Administration.Backups",
  6.                         "Microsoft.SharePoint.Administration.ConfigurationWizards",
  7.                         // we don't need duplicates, so we eliminate this one
  8.                         //"Microsoft.SharePoint.Administration.Default",
  9.                         "Microsoft.SharePoint.Administration.GeneralApplicationSettings",
  10.                         "Microsoft.SharePoint.Administration.Monitoring",
  11.                         "Microsoft.SharePoint.Administration.Security",
  12.                         "Microsoft.SharePoint.Administration.SystemSettings",
  13.                         "Microsoft.SharePoint.Administration.UpgradeAndMigration"
  14.                      };
  15.  
  16.  
  17. private void ExtractCustomActions()
  18. {
  19.     List<CustomActionGroup> allCustomActionGroups = new List<CustomActionGroup>();
  20.  
  21.     // get the site collection of the Central Administration web application
  22.     SPAdministrationWebApplication centralAdmin = SPAdministrationWebApplication.Local;
  23.     using (SPSite site = centralAdmin.Sites[0])
  24.     {
  25.         using (SPWeb web = site.OpenWeb())
  26.         {
  27.             locations.ForEach(location =>
  28.                 {
  29.                     List<CustomActionGroup> customActionGroup = GetCustomActionGroups(web, null, location);
  30.                     allCustomActionGroups.AddRange(customActionGroup);
  31.                     customActionGroup.ForEach(cag =>
  32.                         {
  33.                             cag.CustomActions = GetCustomActions(web, null, null, location, cag.Id);
  34.                         });
  35.                 });
  36.         }
  37.     }
  38.  
  39.     string customActions = JsonHelper.JsonSerializer(allCustomActionGroups);
  40.     File.WriteAllText("CustomActions.json", customActions);
  41. }

We should insert the following chunk of HTML code into the start page of the Central Administration, to achieve that I inserted a Content Editor Web Part (CEWP) to the right web part zone of the page. As you can see I utilized jQuery, the LINQ for JavaScript (ver.3.0.3-Beta4) library and jQuery UI autocomplete. The single HTML element is a text field to which we can attach the autocomplete behavior.

  1. <!– jQuery –>
  2. <script type="text/javascript" src="/_layouts/CACustomActions/js/jQuery/jquery-1.8.3.min.js"></script>
  3. <!– LINQ.js –>
  4. <script src="/_layouts/CACustomActions/js/linq.min.js" type="text/javascript"></script>
  5. <!– jQuery UI autocomplete –>
  6. <script type="text/javascript" src="/_layouts/CACustomActions/js/jquery-ui-1.10.3.custom/js/jquery-ui-1.10.3.custom.min.js"></script>
  7. <link rel="stylesheet" type="text/css" href="/_layouts/CACustomActions/js/jquery-ui-1.10.3.custom/css/ui-lightness/jquery-ui-1.10.3.custom.min.css">
  8. <!– Our custom .js / .css components –>
  9. <script src="/_layouts/CACustomActions/js/CustomActions.js" type="text/javascript"></script>
  10. <link rel="stylesheet" type="text/css" href="/_layouts/CACustomActions/css/CustomActions.css">
  11.  
  12. <input id="autocompleteCustomActions" type="text"/>

In the CustomActions.js I have a variable called CustomActions.js that contains the JSON serialized output of the custom actions and groups from our SPCustomActionsExtract tool.

On page load we register an event handler that invokes the updateAutoComplete method whenever we type a text into the text field.

  1. $(document).ready(startScript);              
  2.         
  3. function startScript() {
  4.   registerEvents();
  5. }
  6.  
  7. function registerEvents() {     
  8.        $("#autocompleteCustomActions").keyup(function(e){
  9.        updateAutoComplete();
  10.     });
  11. }

In the first part of the updateAutoComplete method we compare the titles and descriptions of the existing custom actions to the filter value we typed in, and aggregating the result into the matchingCAs array. Next, the items are ordered alphabetically

  1. var searchedCA = $("#autocompleteCustomActions").val().toLowerCase();
  2.  
  3. var matchingCAs = new Array();
  4.  
  5. Enumerable.from(customActions).forEach(function (cag) {
  6.   Enumerable.from(cag.CustomActions).forEach(function (ca) {
  7.     // find the custom action based on the title and the description
  8.     // the comparision is case insensitive
  9.     if ((ca.Title.toLowerCase().indexOf(searchedCA) > -1) || ((ca.Description != undefined) && (ca.Description.toLowerCase().indexOf(searchedCA) > -1))) {
  10.       var desc = (ca.Description != undefined) ? ca.Description  : ""
  11.       matchingCAs.push({
  12.           // HACK?: we have to create a 'label' property that inlcudes the 'Title' and the 'Description'
  13.           // otherwise the item is not shown in the autocomplete list
  14.           // I don't know if it is a feature or a bug
  15.           label: ca.Title + " – " + desc,
  16.           caption: ca.Title,
  17.           description: desc,
  18.           groupTitle: cag.Title,
  19.           urlAction: ca.UrlAction,
  20.           imageUrl: cag.ImageUrl
  21.       });
  22.     }
  23.   });
  24. });
  25.  
  26. matchingCAs = Enumerable.from(matchingCAs).orderBy("$.label").toArray();

Then we display the result in the autocomplete box. We display the icon of the custom action group and the title of the custom action in the list, but as a tool tip the description of the custom action and the title of the group will be display as well. The URL of the custom action is set as a link on the item, so if you select an item, the corresponding page will be opened in the browser.

  1. $("#autocompleteCustomActions").autocomplete({
  2.     sortResults:true,
  3.     source: matchingCAs,
  4.     open: function() {
  5.         $('#autocompleteCustomActions').autocomplete('widget').width(350);
  6.     },
  7.     focus: function(event, ui) {
  8.         $('#autocompleteCustomActions').val(ui.item.label);
  9.         return false;
  10.     }
  11. })
  12. .data("ui-autocomplete")._renderItem = function(ul, item) {
  13.     return $("<li>")
  14.     .append("<a class='ca-text' href='" + item.urlAction + "' title='" + item.description + " (" + item.groupTitle + ")'><img class='ca-img' src='"+ item.imageUrl + "' /><span class='ca-wrapper'>" + item.caption + "</span></a>")
  15.     .appendTo(ul);
  16. };

The screenshots below illustrate the functionality of the sample. As we type the text, the autocomplete list changes dynamically:

image

If you hover over a list item, additionally information is displayed as tool tip, so we can easily decide if it is really the administrative option we need. If you click on an item in the list, the corresponding page will be opened.

image

You can download the sample application (the .json generator tool, web part, .js and .css files) from here.

The deployment steps:

Copy the content of the Layouts folder from the sample into the LAYOUTS folder (C:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\14\TEMPLATE\LAYOUTS) of your SharePoint server.

If your Central Administration contains special custom actions, you can (and should) update the value of the customActions variable at the very beginning of the CustomActions.js file (in the CACustomActions\js folder in LAYOUTS) with the content of the CustomActions.json generated by the SPCustomActionsExtract.exe tool. You should find the .json file in the bin\Debug folder of the project. The .json file I used (and that is included in the sample) was generated from a standard installation of the English version of SharePoint Server 2010 Enterprise edition, so if you have an other version (like Foundation or a German version) you should definitely regenerate the file and update the CustomActions.js file with the result.

Note: If you don’t like the idea of using a command line tool to (re)generate the structure queried from JavaScript, it is viable option to create a custom web part that performs the same actions on-demand (that means, on each page request) as this tool does, and includes the client side components (like JavaScript and css) as well. However, I found that since the registered custom actions do not change very frequently, so creating and deploying a web part seemed me a bit overkill in this case.

The web part (see WebPart\Find the Page You Need.dwp) can be imported to the start page of the the Central Administration, or if you find that easier, you can add a Content Editor Web Part (CEWP) to the page and set its source code based on the code snippet in this post above.

Leave a Comment »

No comments yet.

RSS feed for comments on this post. TrackBack URI

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Blog at WordPress.com.

%d bloggers like this: