Friday, 5 February 2010

Ribbon customizations - dropdown controls, Client Object Model and JavaScript Page Components

In this article series:

  1. Customizing the ribbon – creating tabs, groups and controls
  2. Adding ribbon items into existing tabs/groups
  3. Ribbon customizations - dropdown controls, Client Object Model and JavaScript Page Components (this post)
  4. Customize the ribbon programmatically from web parts and field controls

Once you understand how to get your customizations into the right place in the ribbon, you may find you want to go beyond simply adding buttons and make use of other controls such as dropdowns, checkboxes, flyout anchors and so on. This type of ribbon development is fairly involved, but as with so many things in SharePoint development, once you’ve done it the first time you know the overall “template” for subsequent occasions - hopefully what I’m showing here is a good starting point for quite a few advanced things you might want to do. The key is that a JavaScript “page component” is generally required in addition to the declarative XML we’ve seen in my earlier posts.

[Beta sidenote] At the time of writing (Feb 2010, still in beta), after some time on this I’m forming the opinion that ribbon development could be one of the more complex development areas in SP2010. Maybe some stellar documentation from Microsoft could change this, but right now there are many dark corners which are quite difficult to understand – currently there is practically no coverage of much of this stuff in the SDK (and very little anywhere really), so unless you have inside information it’s mainly blood, sweat and tears all the way. I’ve mentioned this to the PM in the Product Group (Elisabeth Olson), and it sounds like more MS guidance is on the way soon, so let’s hope.

The sample

My sample shows the use of a custom dropdown in the ribbon – I’ll go into more detail later, but the concepts I’m showing here can be used for many controls in the ribbon, not just a dropdown. So if what you’re wanting to do doesn’t specifically involve a dropdown, I’d suggest reading on anyway as this technique is still probably what you will use.

When clicked the first time, it uses the Client Object Model to fetch a collection of lists in the current web, then presents them as options in the dropdown:

CustomDropdownInRibbon

CustomDropdownInRibbonExpanded

When an item is selected, a simple JavaScript alert is raised with the name of the selected list, though a real-life implementation would of course do something more useful with the value. The goal here is to illustrate how to work with ribbon controls other than buttons, and also how to write code behind them – once you can do this, you’ll be able to build a wide range of solutions.  

One key thing to note – it IS possible to add items to a dropdown or other control entirely in XML. I’m choosing to use a JavaScript page component to illustrate what happens when you need “code-behind” e.g. to iterate all the lists in a web in my case.

What’s required – summary

  1. Declarative XML to provision the ribbon controls
  2. JavaScript “page component”, typically declared in external .js file
  3. Addition of JavaScript to page (using whatever technique is most appropriate to the scope of your ribbon customization – code in a web part/delegate control which is added to AdditionalPageHead, etc.). This will:
    1. Link the external .js file
    2. Ensure core dependent .js files are loaded e.g. SP.js, CUI.js
    3. Call into the initialization function within our page component – this registers our component with the ribbon framework and ensures our component gets added to the page.

1. Declarative XML

I used the following XML – here I’m actually showing a cut-down extract which is just for the group containing my controls. Really it’s just the ‘Controls’ section which is the most interesting bit, the surroundings would depend on whether you are wanting to create a new tab or add the items into an existing tab/group, see my previous articles for those details.

Key points of note are the Command, PopulateQueryCommand, and QueryCommand attributes on the dropdown – these will link into our JavaScript page component:

<Group
  Id="COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup"
  Description="Contains advanced ribbon controls"
  Title="Page component sample"
  Sequence="53"
  Template="Ribbon.Templates.COB.OneLargeExample">
  <Controls Id="COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Controls">
    <Label Id="COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Label" 
           ForId="COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown" 
           Command="LabelCommand"
           LabelText="Select list:"
           Sequence="16" 
           TemplateAlias="c1"/>
    <DropDown
      Id="COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown"
      Sequence="17"
      Command="COB.PageComponent.Command.DoAction"
      PopulateDynamically="true"
      PopulateOnlyOnce="true"
      PopulateQueryCommand="COB.PageComponent.Command.PopulateDropDown"
      QueryCommand="COB.PageComponent.Command.QueryDoAction"
      Width="75px"
      TemplateAlias="c2" />
  </Controls>
</Group>

2. JavaScript page component

This is the complex bit, the first time at least. We are effectively writing object-oriented JavaScript which contains a class which powers our ribbon control. Consider JavaScript such as this the ‘template’ to use for page components, where you’ll modify the actual implementation bits each time. I’ve commented some key points, suggest having a scroll through and then we’ll walk through the highlights:

Type.registerNamespace('COB.SharePoint.Ribbon.PageComponent');
 
COB.SharePoint.Ribbon.PageComponent = function () {
    COB.SharePoint.Ribbon.PageComponent.initializeBase(this);
}
 
// the initialize function needs to be called by some script added to the page elsewhere - in the end, it does the important work 
// of calling PageManager.addPageComponent()..
COB.SharePoint.Ribbon.PageComponent.initialize = function () {
    ExecuteOrDelayUntilScriptLoaded(Function.createDelegate(null, COB.SharePoint.Ribbon.PageComponent.initializePageComponent), 'SP.Ribbon.js');
}
COB.SharePoint.Ribbon.PageComponent.initializePageComponent = function() {
    
    var ribbonPageManager = SP.Ribbon.PageManager.get_instance();
    if (null !== ribbonPageManager) {
        ribbonPageManager.addPageComponent(COB.SharePoint.Ribbon.PageComponent.instance);
    }
}
 
COB.SharePoint.Ribbon.PageComponent.prototype = {
    init: function () { },
 
    getFocusedCommands: function () {
        return ['COB.PageComponent.Command.FieldControl.GroupCommand', 'COB.PageComponent.Command.FieldControl.TabCommand', 'COB.PageComponent.Command.FieldControl.ContextualGroupCommand', 'COB.PageComponent.Command.FieldControl.RibbonCommand'];
    },
 
    getGlobalCommands: function () {
        return ['COB.PageComponent.Command.DoAction', 'COB.PageComponent.Command.PopulateDropDown', 'COB.PageComponent.Command.QueryDoAction'];
    },
 
    canHandleCommand: function (commandId) {
        if ((commandId === 'COB.PageComponent.Command.DoAction') ||
            (commandId === 'COB.PageComponent.Command.PopulateDropDown') || (commandId === 'COB.PageComponent.Command.QueryDoAction')) {
            return true;        
        }
        else {
            return false;
        }
    },
 
    handleCommand: function (commandId, properties, sequence) {
        if (commandId === 'COB.PageComponent.Command.FieldControl.GroupCommand') {
            alert("COB.PageComponent.Command.FieldControl.GroupCommand fired");
        }
        if (commandId === 'COB.PageComponent.Command.FieldControl.TabCommand') {
            alert("COB.PageComponent.Command.FieldControl.TabCommand fired");
        }
        if (commandId === 'COB.PageComponent.Command.FieldControl.ContextualGroupCommand') {
            alert("COB.PageComponent.Command.FieldControl.ContextualGroupCommand fired");
        }
        if (commandId === 'COB.PageComponent.Command.FieldControl.RibbonCommand') {
            alert("COB.PageComponent.Command.FieldControl.RibbonCommand fired");
        }
        if (commandId === 'COB.PageComponent.Command.QueryDoAction') {
            // this command executes as soon as tab is requested, so do initialization here ready for if our dropdown gets requested..
            loadCurrentWebLists();
        }
        if (commandId === 'COB.PageComponent.Command.PopulateDropDown') {
            // actually build the dropdown contents by setting the PopulationXML property to a value with the expected format. We have to deal with possible 
            // timing issues/dependency on core SharePoint JS code with an ExecuteOrDelay..
            ExecuteOrDelayUntilScriptLoaded(Function.createDelegate(null, getDropdownItemsXml), 'SP.js');
 
            properties.PopulationXML = getDropdownItemsXml();
        }
        if (commandId === 'COB.PageComponent.Command.DoAction') {
            // here we're using the SourceControlId to detect the selected item, but more normally each item would have a unique commandId (rather than 'DoAction'). 
            // However this isn't possible in this case since each item is a list in the current web, and this can change..
            var selectedItem = properties.SourceControlId.toString();
            var listName = selectedItem.substring(selectedItem.lastIndexOf('.') + 1);
            alert("You selected the list: " + listName);
        }
    },
 
    isFocusable: function () {
        return true;
    },
 
    receiveFocus: function () {
        return true;
    },
 
    yieldFocus: function () {
        return true;
    }
}
 
// **** BEGIN: helper code specific to this sample ****
 
// some global variables which we'll use with the async processing..
var lists = null;
var querySucceeded = false;
 
// use the Client Object Model to fetch the lists in the current site..        
function loadCurrentWebLists() {
    var clientContext = new SP.ClientContext.get_current();
    var web = clientContext.get_web();
    this.lists = web.get_lists();
 
    clientContext.load(lists);
    clientContext.executeQueryAsync(
           Function.createDelegate(this, this.onQuerySucceeded),
           Function.createDelegate(this, this.onQueryFailed));
}
 
function onQuerySucceeded() {
    querySucceeded = true;
}
 
function onQueryFailed(sender, args) {
    querySucceeded = false;
}
 
function getDropdownItemsXml() {
    var sb = new Sys.StringBuilder();
    sb.append('<Menu Id=\'COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown.Menu\'>');
    sb.append('<MenuSection DisplayMode=\'Menu\' Id=\'COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown.Menu.Manage\'>');
    sb.append('<Controls Id=\'COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown.Menu.Manage.Controls\'>');
    if (querySucceeded)
    {
        var listEnumerator = lists.getEnumerator();
 
        while (listEnumerator.moveNext()) {
            var oList = listEnumerator.get_current();
            
            sb.append('<Button');
            sb.append(' Id=\'COB.SharePoint.Ribbon.WithPageComponent.PCNotificationGroup.Dropdown.Menu.Manage.');
            sb.append(oList.get_title());
            sb.append('\'');
            sb.append(' Command=\'');
            sb.append('COB.PageComponent.Command.DoAction');
            sb.append('\'');
            sb.append(' LabelText=\'');
            sb.append(SP.Utilities.HttpUtility.htmlEncode(oList.get_title()));
            sb.append('\'');
            sb.append('/>');
        }
    }
    sb.append('</Controls>');
    sb.append('</MenuSection>');
    sb.append('</Menu>');
    return sb.toString();
}
  
// **** END: helper code specific to this sample ****
 
COB.SharePoint.Ribbon.PageComponent.registerClass('COB.SharePoint.Ribbon.PageComponent', CUI.Page.PageComponent);
COB.SharePoint.Ribbon.PageComponent.instance = new COB.SharePoint.Ribbon.PageComponent();
 
NotifyScriptLoadedAndExecuteWaitingJobs("COB.SharePoint.Ribbon.PageComponent.js");
 
  • The ‘initialize’ function is typically responsible for calling ‘addPageComponent’ on the ribbon PageManager (but not before SP.Ribbon.js has loaded)
  • The commands referenced in the JS are those specified in the control XML e.g. for my dropdown
    • The ‘getFocusedCommands’ function returns an array of commands which should execute when my control has focus
    • The ‘getGlobalCommands’ function returns an array of commands which should execute regardless of focus
    • We need to list the commands which can be handled in the ‘canHandleCommand’ function, and provide the actual implementation for each of these in ‘handleCommand’
  • Note the following crucial points about the various commands used:
    • PopulateQueryCommand – used to build the list of items in the control. This is where I’m using the Client Object Model (ECMAScript version) to fetch the lists for the current web.
    • QueryCommand – called when the parent container (e.g. tab) is activated. Remember the ribbon is all about “script on demand” (lazy loading), so I’m choosing this as a better place to do my initialization work of the actual Client OM request – more on this later.
    • Command – called when the user actually selects an item
  • IMPORTANT – these commands apply to lots of ribbon controls other than dropdowns e.g. FlyoutAnchor, SplitButton, ToggleButton, TextBox, Checkbox, Spinner, etc. This is why this information is relevant even if it’s not specifically a dropdown control you’re working with.
  • The key to populating controls which take collections is to use the ‘properties’ object passed to handleCommand, using either: ul>
  • properties.PopulationXML
  • properties.PopulationJSON
  • I’m using properties.PopulationXML to supply the items which should appear in my dropdown, and the format required is:
       1: <Menu Id="">
       2:   <MenuSection Id="">
       3:     <Controls Id="">
       4:       <Button Command="" Id="" LabelText="" />
       5:       ..a 'Button' element here for each item in the collection..
       6:     </Controls>
       7:   </MenuSection>
       8: </Menu>
    I haven’t yet seen an example of how to use the .PopulationJSON property, so don’t know the exact names to use in the JSON.
  • There are some interesting facets to combining the Client OM with the ribbon JS framework – effectively the async model used means the result of your method call may not be ready by the time the ribbon framework needs it (it happens on a different request after all). I’ll explain how I dealt with this in my example towards the end of this article.

3. Page-level JavaScript

The final element is the JavaScript you need to add to the page to call into the page component. In my example I’m happy for this JavaScript to be added to every page in my site (since that’s the scope of my ribbon customization), so I used a delegate control in AdditionalPageHead to add a custom user control, the body of which looks like this: 

<SharePoint:ScriptLink Name="sp.js" LoadAfterUI="true" OnDemand="false" Localizable="false" runat="server" ID="ScriptLink1" />
<SharePoint:ScriptLink Name="CUI.js" LoadAfterUI="true" OnDemand="false" Localizable="false" runat="server" ID="ScriptLink3" />
<SharePoint:ScriptLink Name="/_layouts/COB.SharePoint.Demos.Ribbon/COB.SharePoint.Ribbon.PageComponent.js" LoadAfterUI="true" OnDemand="false" Localizable="false" runat="server" ID="ScriptLink2" />
    <script type="text/javascript">
     
        //<![CDATA[
            function initCOBRibbon() {
                COB.SharePoint.Ribbon.PageComponent.initialize();
            }
     
            ExecuteOrDelayUntilScriptLoaded(initCOBRibbon, 'COB.SharePoint.Ribbon.PageComponent.js');
    //    
    //]]>
</script>

The important things here are that we ensure required system JS files are loaded with the ScriptLink tag, do the same for our JS file, then call the .initialize() function of our page component.

So those the component pieces for complex controls in the ribbon! A wide variety of ribbon customizations should be possible by tailoring this information/sample code as needed (remember ‘handleCommand’ is the key implementation hook), and I definitely think that starting from such a template is the way to go.

Appendix - considerations for using the Client Object Model in the ribbon

When working with the ribbon it quickly becomes apparent that if the Client Object Model didn’t exist, things would be much trickier – they are a natural pairing for many requirements. Despite this, some challenges arise – consider that a control (e.g. dropdown) will have it’s items collection populated as late as possible if ‘PopulateDynamically’ is set to true (generally a good idea) i.e. when the dropdown is actually clicked to select an item! This is because the ribbon is designed around a “script on demand” model (you’ll often see “SOD” references in Microsoft’s JavaScript) – this ensures only the required JavaScript is downloaded to the client, and no more. This solves the issue where on SharePoint 2007 WCM sites, we would suppress the core.js file for anonymous users because it was big and not required for these users. Anyway, when the dropdown is clicked, at this point the ribbon framework calls ‘handleCommand’ with the command specified for the ‘PopulateQueryCommand’ value. If you run your Client OM code here it’s no good, since you won’t get the result there and then due to the async model – the result will be provided to the callback function, long after ‘handleCommand’ has completed, so the end result is your control will be empty.

Consequently, you need to do the actual processing before the ‘PopulateQueryCommand’ is called. You could choose to do as soon as the page loads, but in most cases this could be inefficient – what if the user doesn’t come near your ribbon control on this page load? In this case we would have incurred some client-side processing and an extra request to the server which was completely unnecessary – on a high-traffic page, this could be bad news. Without any documentation it’s hard to be sure at this stage, but it seems the ‘QueryCommand’ is a good place to put such Client OM code – this seems to be called when the parent container (e.g. tab) is made visible (at which point there’s now a chance the user could use our control). In my code I have the actual Client OM query run here and store the result in page-level variable - this is then picked up and iterated for the ‘PopulateQueryCommand’. By the time the script runs this command to populate the control, the query has already executed and has the data ready – happy days. I’ll be interested to see what emerges in this area to see whether this is the expected pattern or, well, if I’ve got things completely wrong.

Summary

Complex ribbon customizations are likely to require a JavaScript page component - in general page components are somewhat complex (partly because of the current lack of documentation perhaps), but once you have a suitable template, subsequent implementations should be easier. If you need to use Client Object Model code, beware of the “async/lifecycle issue” discussed here and ensure your code has data ready for the ribbon framework.

14 comments:

Nigel Price said...

Will all of this still work if Java is switched off in the Browser ?

Chris O'Brien said...

Ah - remember that the ribbon is used by people who can contribute to SharePoint sites, not say, anonymous users. As such, JavaScript is absolutely required for those users, and has been all the way back to Sharepoint 2003 I think.

ikarstein said...

Hi Chris,

I found a way to activate a Ribbon tab in javascript at runtime.

Please see my blog post:

http://ikarstein.wordpress.com/2010/06/15/how-to-activate-sharepoint-ribbon-tab-by-javascript-code/

Chris O'Brien said...

@ikarstein,

That looks very useful - good find, thanks for posting!

Chris.

Rob said...

Hi Chris,

Can you give me a quick heads up on how to change the ribbons editing tools fontsize dropdown from pt to px?

Chris O'Brien said...

@Rob,

I don't think that would be a simple exercise - when the dropdown is changed, some of Microsoft's code will run. You'd need to somehow override this, but my guess is that MS didn't easily provide a way to do this, since it's unlikely to be a common request.

Thanks,

Chris.

Anonymous said...

Would it be possible to post a solution file for this?

Chris O'Brien said...

@Anonymous,

Really sorry, but I can't easily supply the original solution file - to my shame, I lost the virtual machine at a time when my automated backups weren't actually working.

Is there anything in particular I could help with?

Thanks,

Chris.

Anonymous said...

Hi Chris,

This is anonymous again...:) No problem about the solution files. I have to dump my VM every 180 days as well. Two questions I had -
1) The enabling and disabling of the status buttons does not work for me. It does work the first time the page is loaded. I tested this by reversing the true/false conditions. But subsequent clicks to the Add Status button does not cause the EnabledScript to fire...not sure why.

2) In part 3 where the pageComponent is used. I am trying to add the web control for just one page that I am testing. So should I just do an edit page and then add the web control to that page? Is that sufficient for it to run when the page loads?

Thanks in advance,
Jake.

KS said...

I have own contextual group/tab/controls dynamically created via RegisterDataExtension from custom web parts.

The problem is also sporadic but fairly frequent. When problem occurs, all Ribbon buttons renders but were disabled and JavaScript error shows as:

object expected: pagecomponent.js error

It fails at PageComponent.js at the following location (return statement):



getGlobalCommands: function

() {
ULS_SP();
return

getGlobalCommands();
},
Any idea?

Chris O'Brien said...

@KS,

Hmm, I'm not sure I've used the technique you mention. In any case, I can't see anything immediately wrong with what you've posted. You could try Wictor Wilen - Wictor has looked fairly deeply at ribbon customizations from web parts.

HTH,

Chris.

mente said...

Hello Chris,

First i'd like to thank you for your post and all your useful work. I've followed your example but i can't get the dropdown to work - it's disabled. I've checked that all javascript has been correctly loaded. Any ideias?

Filipe.

Chris O'Brien said...

@mente,

If you're sure everything is loaded and that you're calling into the init method, there's one thing that springs to mind. I think Microsoft made an internal change in RTM from when I wrote this code (beta 2) - you need to work with a private JavaScript variable to help the ribbon understand whether your command can be called. See Andrew Connell's post at http://www.andrewconnell.com/blog/archive/2010/10/14/asynchronously-checking-if-a-command-is-available-in-the-sharepoint.aspx

HTH,

Chris.

Tyler said...

Hi Chris, I wanted to see if you knew how to force all contextual groups to be visible on a page. The specific scenario is that we would like to hae a content editor webpart on a list view page but we would still like the list contextual group visible on the page without the user knowing they have to click into the list.