Cross-Site Collection Navigation

Background

SharePoint uses the provider model for supplying data to its navigation controls. The v4.master, for instance, uses an AspMenu control for the top navigation whose DataSourceId is set declaratively to an instance of a SiteMapDataSource. The data source control is wrapped in a delegate control so it can be easily swapped out using a feature.

<SharePoint:AspMenu
  ID="TopNavigationMenuV4"
  Runat="server"
  EnableViewState="false"
  DataSourceID="topSiteMap"
  AccessKey="<%$Resources:wss,navigation_accesskey%>"
  UseSimpleRendering="true"
  UseSeparateCss="false"
  Orientation="Horizontal"
  StaticDisplayLevels="2"
  MaximumDynamicDisplayLevels="1"
  SkipLinkText=""
  CssClass="s4-tn" />
<SharePoint:DelegateControl runat="server" ControlId="TopNavigationDataSource" Id="topNavigationDelegate">
    <Template_Controls>
        <asp:SiteMapDataSource
          ShowStartingNode="False"
          SiteMapProvider="SPNavigationProvider"
          id="topSiteMap"
          runat="server"
          StartingNodeUrl="sid:1002"/>
    </Template_Controls>
</SharePoint:DelegateControl>

The data source control has a SiteMapProvider property which can be set to any of the named sitemap providers declared in the web.config file.

<siteMap defaultProvider="CurrentNavigation" enabled="true">
  <providers>
    <add name="SPNavigationProvider" type="Microsoft.SharePoint.Navigation.SPNavigationProvider, Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="SPSiteMapProvider" type="Microsoft.SharePoint.Navigation.SPSiteMapProvider, Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="SPContentMapProvider" type="Microsoft.SharePoint.Navigation.SPContentMapProvider, Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="SPXmlContentMapProvider" siteMapFile="_app_bin/layouts.sitemap" type="Microsoft.SharePoint.Navigation.SPXmlContentMapProvider, Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="ExtendedSearchXmlContentMapProvider" description="Provider for navigation in Extended Search pages" siteMapFile="_app_bin/layouts.sitemap" type="Microsoft.Office.Server.Search.Extended.Administration.Common.ExtendedSearchXmlContentMapProvider, Microsoft.Office.Server.Search, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="AdministrationQuickLaunchProvider" description="QuickLaunch navigation provider for the central administration site" type="Microsoft.Office.Server.Web.AdministrationQuickLaunchProvider, Microsoft.Office.Server.UI, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="SharedServicesQuickLaunchProvider" description="QuickLaunch navigation provider for shared services administration sites" type="Microsoft.Office.Server.Web.SharedServicesQuickLaunchProvider, Microsoft.Office.Server.UI, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="GlobalNavSiteMapProvider" description="CMS provider for Global navigation" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Global" EncodeOutput="true" />
    <add name="CombinedNavSiteMapProvider" description="CMS provider for Combined navigation" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Combined" EncodeOutput="true" />
    <add name="CurrentNavSiteMapProvider" description="CMS provider for Current navigation" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Current" EncodeOutput="true" />
    <add name="CurrentNavSiteMapProviderNoEncode" description="CMS provider for Current navigation, no encoding of output" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Current" EncodeOutput="false" />
    <add name="GlobalNavigation" description="Provider for MOSS Global Navigation" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Combined" Version="14" />
    <add name="CurrentNavigation" description="Provider for MOSS Current Navigation" type="Microsoft.SharePoint.Publishing.Navigation.PortalSiteMapProvider, Microsoft.SharePoint.Publishing, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" NavigationType="Current" Version="14" />
    <add name="SiteDirectoryCategoryProvider" description="Site Directory category provider" type="Microsoft.SharePoint.Portal.WebControls.SiteDirectoryCategoryProvider, Microsoft.SharePoint.Portal, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="MySiteMapProvider" description="MySite provider that returns areas and based on the current user context" type="Microsoft.SharePoint.Portal.MySiteMapProvider, Microsoft.SharePoint.Portal, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="MySiteLeftNavProvider" description="MySite Left Nav provider that returns areas and based on the current user context" type="Microsoft.SharePoint.Portal.MySiteLeftNavProvider, Microsoft.SharePoint.Portal, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
    <add name="MySiteSubNavProvider" description="MySite Sub Nav provider that returns areas and based on the current user context" type="Microsoft.SharePoint.Portal.MySiteSubNavProvider, Microsoft.SharePoint.Portal, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
  </providers>
</siteMap>


Problem

This arrangement works well when you stay within a site collection. However, if you need to show navigation nodes from another site collection you will need another approach. The problem is that the SiteMapProvider is limited to returning only nodes in the current site collection. If you have created, for example, a My Site Host site collection, and want to display the root site collection’s top navigation at the top of the page instead of the default My Site Host navigation, you need a way to read the root site collection’s navigation nodes.

Solution

The solution provided below is for a publishing site and uses the PortalSiteMapProvider. The PortalSiteMapProvider has a method called GlobalNavSiteMapProvider which can be used to get the top navigation nodes as a SiteMapNodeCollection. We can interate over these nodes in a custom control and build up the navigation for our site in whatever way we want.

But, to return a SiteMapNodeCollection from another site collection we need a reference to the PortalSiteMapProvider in the context of the other site collection. One way to do this is to use an HTTP handler that can be called from the Layouts folder (e.g., _layouts/handlers/navigation.ashx) and then add the appropriate site collection’s URL. If we look at the following three site collections, their URL’s all point to the same HTTP handler:

If we call the first URL from either the Search Center or My Site Host site collections, the PortalSiteMapProvider, if it is referenced in the handler, will return navigation nodes from the root site collection. Note that this method will only work within a single web application. If we attempt to reach across applications this would amount to cross-site scripting which is prevented for security reasons.

The custom handler can then be called asynchronously from client code and a serialized set of navigation nodes returned to the browser and formatted using client script.

    Implementation

To implement this solution, add an HTTP handler to your project. The following assumes you have a mapped Layouts folder in a Visual Studio 2010 SharePoint project. Right-click the Layouts folder and Add an ASP.NET Handler from the Web item list.

image

This will create a class that inherits from IHttpHandler which requires you to implement a single method, ProcessRequest, and a single property, IsReusable.

public void ProcessRequest(HttpContext context)
{
    SPSecurity.RunWithElevatedPrivileges(GetGlobalNav);
}

public bool IsReusable
{
    get { return true; }
}

The IsReusable property allows subsequent requests to reuse the same instance of the handler. ProcessRequest acts as our entry point and, to ensure our method that retrieves navigation nodes has sufficient privileges, we call it with RunWithElevatedPrivileges.

The GetGlobalNav method then gets a reference to the PortalSiteMapProvider which then gets a SiteMapNodeCollection based on the root node. This collection is first passed into a method that converts it into a collection that can be easily serialized and then finally we call SerializeWrite to output JSON to the client.

private void GetGlobalNav()
{
    try
    {
        //Note: PortalSiteMapProvider returns data for the current request's site collection
        var rootNode = PortalSiteMapProvider.GlobalNavSiteMapProvider.RootNode;
        if (rootNode != null)
        {
            var nodes = PortalSiteMapProvider.GlobalNavSiteMapProvider.GetChildNodes(rootNode);
            SerializeWrite(GetSerializableSiteMap(nodes));
        }
    }
    catch (Exception e)
    {
        //log exception
    }
}

Since the generic List<t> can be easily serialized if we use a custom struct, all we need to do in GetSerializableSiteMap is build up a List using the specified SiteMapNodeCollection.

private List<SerializableSiteMapNode> GetSerializableSiteMap(SiteMapNodeCollection nodes)
{
    var serializableSiteMap = new List<SerializableSiteMapNode>();

    foreach (SiteMapNode node in nodes)
    {
        var serializableSiteMapNode = new SerializableSiteMapNode()
        {
            Description = node.Description,
            HasChildNodes = node.HasChildNodes,
            Key = node.Key,
            Title = node.Title,
            Url = node.Url
        };

        if (_siteMapLevel < Depth && node.HasChildNodes)
        {
            _siteMapLevel++;
            serializableSiteMapNode.ChildNodes = GetSerializableSiteMap(node.ChildNodes);
        }

        serializableSiteMap.Add(serializableSiteMapNode);
    }

    return serializableSiteMap;
}

public struct SerializableSiteMapNode
{
    public List<SerializableSiteMapNode> ChildNodes;
    public string Description;
    public bool HasChildNodes;
    public string Key;
    public string Title;
    public string Url;
}

The result of this is passed into a method that handles writing the HTTP response. Fortunately, .NET has a JavaScript serializer which makes our job easier.

private void SerializeWrite(object objectToWrite)
{
    var outputString = String.Empty;

    var serializer = new JavaScriptSerializer();
    outputString = serializer.Serialize(objectToWrite);

    HttpContext.Current.Response.Write(outputString);
}

The custom handler can be called in a user control using the appropriate site collection URL and the response data formatted using jQuery. We have added a user control to the top of the master page in our My Site Host site collection that calls our handler using a relative reference to the root site collection ("/"). So while we display the navigation in the My Site Host site collection, the nodes will be pulled from the root (main) site collection using the URL "/_layouts/handlers/navigation.ashx". The user control loads an empty unordered list element whose list items are built up asynchronously based on the response from our handler.

<script language="javascript" type="text/javascript">
    var handlerUrl = '/_layouts/handlers/navigation.ashx?&Depth=2';
    var level = 0;

    $(document).ready(function () {
        ul = $('#navGlobal');
        ul.children().remove();

        $.get(handlerUrl, function (data) {
            if (data != '') {
                tags = eval(data);
                writeNodes(tags, ul);
            }
        });
    });

    function writeNodes(tagNodes, ulNode) {
        $(tagNodes).each(function (i, n) {
            if (n.Url == null || n.Title == null) return true;

            var a = $(document.createElement('a'));
            a.html(n.Title);
            a.attr('href', n.Url);
            if (level == 0) a.attr('class', 'top_link');

            var li = $(document.createElement('li'));
            li.append(a);

            if (n.HasChildNodes) {
                level++;
                var ul = $(document.createElement('ul'));
                writeNodes(n.ChildNodes, ul);
                li.append(ul);
            }

            ulNode.append(li);
        });
    }
</script>

<ul id="navGlobal" />

This is a simple approach to simply reading and displaying navigation nodes from one site collection in another. We have not tried to merge navigation nodes using this method but I don’t see why one could not.

In addition, we have only used our handler to return data to the client. If you need to return it to server-side code, you can simply create a WebRequest object on the server that calls the handler. The data should then be serialized as XML and not JSON so you can use the .NET XML object model to handle the response.

Creating Managed Properties

Managed properties for search can be created using a feature and they can be mapped to crawled properties. See Creating Crawled Properties for setting up your crawled properties first without needing to run a full crawl during deployment. Managed properties have to be set up before you can query on any metadata field (SharePoint, People, Mail, etc.).

In order to be able to manage the creation of managed properties and how they get mapped to crawled properties we created a manager class. When this class gets instantiated it creates a reference to the specified search service and search service application on the local farm.

image

Once we have a reference to the service application we can store the search schema in a private field. The Schema  holds all of the crawled, managed, and mapped properties needed for search.

image 

From the schema we can get collections of all the crawled and managed properties and store them in private fields in our manager as well.

image

Then, once the manager has been instantiated, the add method can be called repeatedly to add a series of properties. If the property already exists we retrieve it for updating it’s mappings to crawled properties. If the retrieved property already has mappings we remove those that match the current managed property name in order to update them from scratch.

    /// <summary>
    /// A manager for adding and updating managed properties for Enterprise Search.
    /// </summary>
    public class ManagedPropertyManager
    {
        private Schema _schema;
        private IEnumerable<CrawledProperty> _crawledProperties;
        private ManagedPropertyCollection _allManagedProperties;

        public ManagedPropertyManager(string serviceName, string applicationName)
        {
            SPFarm farm = SPFarm.Local;
            SearchService searchService = farm.Services.GetValue<SearchService>(serviceName);
            SearchServiceApplication searchApp =
                searchService.SearchApplications.GetValue<SearchServiceApplication>(applicationName);
            _schema = new Schema(searchApp);
            _crawledProperties = _schema.QueryCrawledProperties(string.Empty, 1000000, Guid.NewGuid(),
                string.Empty, true).Cast<CrawledProperty>();
            _allManagedProperties = _schema.AllManagedProperties;
        }

        /// <summary>
        /// Adds a managed property for search that is mapped to the specified crawled property.
        /// An exception is thrown if the crawled property is not found.
        /// </summary>
        /// <param name="propertyName">The name of the managed property.</param>
        /// <param name="propSet">The crawled property's property set for which a mapping is being added.</param>
        /// <param name="dataType">The data type of the managed property.</param>
        public void AddPropertyWithMapping(string propertyName, PropertySet propSet, ManagedDataType dataType)
        {
            if (string.IsNullOrEmpty(propertyName))
            {
                throw new ArgumentNullException("propertyName");
            }

            ManagedProperty property = null;

            if (_allManagedProperties.Contains(propertyName))
            {
                property = _allManagedProperties[propertyName];
            }
            else
            {
                try
                {
                    property = _allManagedProperties.Create(propertyName, dataType);
                }
                catch (Exception ex)
                {
                    //log exception
                }
            }

            if (property != null)
            {
                var mappings = property.GetMappings();
                var crawledPropertyName = Settings.GetCrawledPropertyPrefix(propSet) + propertyName;
                var crawledProperty = _crawledProperties.FirstOrDefault(c => c.Name.Equals(crawledPropertyName));

                if (crawledProperty != null)
                {
                    for (int i = 0; i < mappings.Count; i++)
                    {
                        if (string.Compare(mappings[i].CrawledPropertyName, crawledProperty.Name, true) == 0)
                        {
                            mappings.Remove(mappings[i]);
                        }
                    }

                    var mapping = new Mapping(
                        crawledProperty.Propset,
                        crawledProperty.Name,
                        crawledProperty.VariantType,
                        property.PID);

                    if (!mappings.Contains(mapping))
                    {
                        mappings.Add(mapping);
                        property.SetMappings(mappings);
                        property.Update();
                    }
                }
                else
                {
                    //throw exception
                }
            }
            else
            {
                //throw exception
            }
        }
    }

The ManagedPropertyManager can then be used in a feature receiver to provision managed properties on deployment.

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    _properties = properties;

    try
    {
        var manager = new ManagedPropertyManager(
            SearchServiceName.EnterpriseSearch,
            SearchServiceApplicationName.Default);

        manager.AddPropertyWithMapping("Property1", PropertySet.SharePoint, ManagedDataType.Text);
        manager.AddPropertyWithMapping("Property2", PropertySet.Mail, ManagedDataType.YesNo);
        manager.AddPropertyWithMapping("Property3", PropertySet.People, ManagedDataType.DateTime);
    }
    catch (Exception ex)
    {
        //log exception
    }
}

After the feature has finished activating, you will see your properties in the Metadata Properties list in Central Administration.

image

And if you click on the property name you can view and change it’s settings. The mappings to the crawled properties will also be displayed on this page (the AboutMe property is displayed below).

image

Follow

Get every new post delivered to your Inbox.