Thursday, November 20, 2008

Writing a Custom Navigation Provider for MOSS

Update: Also explained the concept and put up the code for the left navigation provider here.

Update: Added the Web.config entries at the bottom.


By popular demand here is the writeup for the Custom Navigation Provider for SharePoint 2007 I wrote last year. Be sure to check it our and send me feedback.


So here is the use case. You would like to create a consistent navigation heirarchy in your SharePoint environment. The OOB navigation is not going to work for you because your site has probably grown to many site collections and having a consistent navigation is a need. You do not want to change your navigation on every site collection when it needs to be changed. The appropriate users want to change the top navigation as needed without having full access the site.

I was faced with these challenges last year and so came up with the idea to write a custom navigation provider that can read from a list. The list can have folder heirarchies and those determine the levels and the dropdowns.


The list images and the changes that need to be made in the master page and web.config file are shown below for this to work.



We created custom site columns, custom content types and then a custom list that used these content types to allow users to easily build hierarchies that the navigation provider could read and deduce the navigation levels. Here is an example of the custom list for the top navigation content. The actual URLs below in the Url Link column have erased, but this should get the point across.




This is a view of the global navigation shared across all site collections. It includes one level of dropdowns, but those can be added by adding the list heirarchies and also tweaking the levels to show in the AspMenu in the SharePoint master page.


Here are the changes that had to be made in the master page.


<sharepoint:aspmenu id="GlobalNav" runat="server" datasourceid="topSiteMap" accesskey="">">
Orientation="Horizontal"
StaticDisplayLevels="1"
MaximumDynamicDisplayLevels="2"
StaticEnableDefaultPopOutImage="false" ItemWrap="true"
DynamicHorizontalOffset="-1" DynamicVerticalOffset="-7"
SkipLinkText=""
StaticSubMenuIndent="0" CssClass="ms-topNavContainerCustom">
<staticmenuitemstyle cssclass="topNavItemCustom" itemspacing="0">
<staticselectedstyle cssclass="topNavSelectedCustom" itemspacing="0">
<statichoverstyle cssclass="topNavHoverCustom">
<dynamicmenustyle cssclass="topNavFlyOutsCustom">
<dynamicmenuitemstyle cssclass="topNavFlyOutsItemCustom">
<dynamichoverstyle cssclass="topNavFlyOutsHoverCustom">
</dynamichoverstyle></dynamicmenuitemstyle></dynamicmenustyle></statichoverstyle></staticselectedstyle></staticmenuitemstyle></sharepoint:aspmenu>

<publishingnavigation:portalsitemapdatasource id="topSiteMap" runat="server" sitemapprovider="CustomTopNavProvider" enableviewstate="true" startfromcurrentnode="true" startingnodeoffset="0" showstartingnode="false" treatstartingnodeascurrent="false">



The code below is pre-SP1. Some things may have slightly changed since then.

#region Code Comment Header
/*******************************************************************************************
* <History>
*
* $History: CustomTopNavProvider.cs $
*
* *****************************************************************************************/
#endregion
using System;
using System.Configuration;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Caching;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Security;
using Microsoft.SharePoint.Publishing;
using Microsoft.SharePoint.Publishing.Navigation;
using Microsoft.SharePoint.Administration;
using CompanyXX.ExceptionManagement;
using System.Security.Permissions;
using System.Security;

namespace CompanyXX.MOSS.Utilities.Navigation.Providers
{
//Assign the neccessary security permissions. TODO - Check the permissions required.
[AspNetHostingPermissionAttribute(SecurityAction.LinkDemand, Level = AspNetHostingPermissionLevel.Minimal)]
[SharePointPermissionAttribute(SecurityAction.LinkDemand, ObjectModel = true)]
[AspNetHostingPermissionAttribute(SecurityAction.InheritanceDemand, Level = AspNetHostingPermissionLevel.Minimal)]
[SharePointPermissionAttribute(SecurityAction.InheritanceDemand, ObjectModel = true)]
//This inherits from the PortalSiteMapProvider class in MOSS, just because it provides some of the functions I need.
//You could just as easily write one for WSS.
public class CustomTopNavProvider : PortalSiteMapProvider
{
//Create the in memory objects for storage and fast retreival
protected SiteMapNodeCollection siteMapNodeColl;

//
protected ArrayList childParentRelationship;

//These are only the top level nodes that will show in the top nav
protected ArrayList topLevelNodes;

private PortalSiteMapNode rootNode = null;

/// <summary>
/// Override the initialize method of the superclass. You must override the Initialize method to write
/// a custom provider.
/// </summary>
/// <param name="name"></param>
/// <param name="config"></param>
public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
{
// Verify that config isn't null
if (config == null)
throw new ArgumentNullException("config is null");

// Assign the provider a default name if it doesn't have one
if (String.IsNullOrEmpty(name))
name = "CustomTopNavProvider";

// Add a default "description" attribute to config if the
// attribute doesn’t exist or is empty
if (string.IsNullOrEmpty(config["description"]))
{
config.Remove("description");
config.Add("description", "CompanyXX Custom top navigation provider");
}

base.Initialize(name, config);

childParentRelationship = new ArrayList();
topLevelNodes = new ArrayList();

//Build the site map in memory
LoadTopNavigationFromList();
}


/// <summary>
/// Load the top navigation into memory on the first call.
/// </summary>
protected virtual void LoadTopNavigationFromList()
{
//Make sure to build the structure in memory only once
lock (this)
{
if (rootNode != null)
{
return;
}
else
{
//Initialiaze for the first time
SPSite rootSite = null;
SPWeb rootWeb = null;
SPList topnavList = null;

try
{
//Clear the top level nodes and the relationships
topLevelNodes.Clear();
childParentRelationship.Clear();

//instantiate sites and lists for now. This setting assumes that the list being
//read from for the global top navigation is in the root web of the site collection listed in web.config.
rootSite = new SPSite(ConfigurationManager.AppSettings["CompanyXXRootSite"]);
rootWeb = rootSite.RootWeb;
topnavList = rootWeb.Lists[ConfigurationManager.AppSettings["TopNavigationListName"]];

//Build the root node
//Note: Any top level site of any site collection is assigned to be the rootNode here, not neccessarily the
//top level site of the main site collection
rootNode = (PortalSiteMapNode)this.RootNode;

//We need to pass the PortalSiteMapNode constructor a PortalWebSiteMapNode object, so here it is
//Note: This is the root node of 1 site collection, but the navigation will be shown in all site collections.
PortalWebSiteMapNode pwsmn = rootNode as PortalWebSiteMapNode;

if (pwsmn != null)
{
//Get the current folder to start. The navigation heirarchy can start at that folder.
SPFolder currentFolder = topnavList.RootFolder.SubFolders[ConfigurationManager.AppSettings["NavigationListStartFolderName"]];

//Build the nodes
BuildListNodes(rootWeb, currentFolder, pwsmn, null, true);
}

}
catch (Exception ex)
{
//There was a problem opening the site or the list.
ExceptionManager.Publish(ex);
}
finally
{
//Dispose of the objects
if (rootWeb != null)
rootWeb.Dispose();

if (rootSite != null)
rootSite.Dispose();
}
}
}
}


/// <summary>
/// Go through the list and build and save the PortalSiteMapNode nodes into memory based on the list heirarchy.
/// </summary>
/// <param name="folder">this is the current folder to look for items</param>
/// <param name="prtlWebSiteMapNode">the parent PortalWeb</param>
/// <param name="parentSiteMapNode">the parent node</param>
/// <param name="rootLevel">true if this is the first level, false if its a rootnode</param>
protected virtual void BuildListNodes(SPWeb currentWeb, SPFolder folder, PortalWebSiteMapNode prtlWebSiteMapNode, PortalSiteMapNode parentSiteMapNode, bool rootLevel)
{
// Get the collection of items from this folder
SPQuery qry = new SPQuery();
qry.Folder = folder;
SortedList orderedNodes = new SortedList();
int counter = 100; //for sorting items

try
{
//Browse through the items in the folder and create PortalSiteMapNodes
SPListItemCollection ic = currentWeb.Lists[folder.ParentListId].GetItems(qry);
foreach (SPListItem subitem in ic)
{
//A SiteMapNode does not have target or audience information
//SiteMapNode smn = new SiteMapNode(this, subitem.ID.ToString(), subitem.GetFormattedValue("UrlText"), subitem.Title, subitem.GetFormattedValue("UrlText"));

//Change the nodeTypes to Authored link for leaf nodes so that the GetChildNodes method is not called for those nodes.
NodeTypes ntypes = NodeTypes.AuthoredLink;
if (subitem.Folder != null)
ntypes = NodeTypes.Default;

//Create a PortalSiteMapNode
PortalSiteMapNode psmn = new PortalSiteMapNode(prtlWebSiteMapNode, subitem.ID.ToString(), ntypes,
subitem.GetFormattedValue(ConfigurationManager.AppSettings["UrlLink"]), subitem.Title,
subitem.GetFormattedValue(ConfigurationManager.AppSettings["UrlDescription"]));

//Error, cannot assign audience and target - read only?? This is bad!!
//psmn.Audience = subitem.GetFormattedValue("Audience");
//psmn.Target = "_blank";

//Order the nodes
try
{
int order = Convert.ToInt32(subitem.GetFormattedValue(ConfigurationManager.AppSettings["ItemOrder"]));
orderedNodes.Add(order, psmn);
}
catch (Exception ex)
{
//This will happen if 2 items are assigned the same order. Push one item to the last order.
orderedNodes.Add(counter++, psmn);
}

//if this is a folder, fetch and build the heirarchy under this folder
if (subitem.Folder != null)
BuildListNodes(currentWeb, subitem.Folder, prtlWebSiteMapNode, psmn, false);
}

//Copy nodes in the right order
foreach (object portalSiteMapNode in orderedNodes.Values)
{
//Add the node to the different collections
if (rootLevel)
topLevelNodes.Add(portalSiteMapNode);

//If the parent node is not null, add the parent and the child relationship
if (parentSiteMapNode != null)
childParentRelationship.Add(new DictionaryEntry(parentSiteMapNode.Key, portalSiteMapNode));
}
}
catch (Exception ex)
{
ExceptionManager.Publish(ex);
throw;
}
}


/// <summary>
/// This method will be called for all nodes and subnodes that can have children under them. For eg, NodeTypes.AuthoringLink type node
/// cannot have child nodes.
/// </summary>
/// <param name="node">The node to find child nodes for</param>
/// <returns>The SiteMapNodeCollection which contains the children of the child nodes</returns>
public override SiteMapNodeCollection GetChildNodes(System.Web.SiteMapNode node)
{
return ComposeNodes(node);
}


/// <summary>
/// Compose nodes when the method is called. At a minimum, this method gets called with the root node of every
/// site collection. We must attach the top level nodes to the root node for this method to get called for those
/// nodes as well.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
public virtual SiteMapNodeCollection ComposeNodes(System.Web.SiteMapNode node)
{
//The SiteMapNodeCollection which represents the children of this node
SiteMapNodeCollection children = new SiteMapNodeCollection();

try
{
//If an absolute rootnode, then add the top level children which are the same for every site collection
if (node == node.RootNode)
{
//Serve it from cache if possible.
//TODO: See if better way to do caching
object topNodes = HttpRuntime.Cache["TopNavRootNodes"];
if (topNodes != null && topNodes is SiteMapNodeCollection)
return ((SiteMapNodeCollection)topNodes);

lock (this)
{
//TODO: Check cache again. Threads may have been waiting at the lock.

//Two options available here.
//1. Reload from the list when cache expires in case that is needed
if (String.Compare(ConfigurationManager.AppSettings["ReloadTopNavOnCacheExpiry"], "true", true) == 1)
{
rootNode = null;
LoadTopNavigationFromList();
}

//Else generate the top level nodes from memory. This must be done regardless of option 1 above
for (int i = 0; i < topLevelNodes.Count; i++)
{
children.Add(topLevelNodes[i] as PortalSiteMapNode);
}

//Add them to the cache
HttpRuntime.Cache["TopNavRootNodes"] = children;
}
}
else
//Else this is a subnode, get only the children of that subnode
{
string nodeKey = node.Key;

//Get the children for this nodeKey from cache if they exist there
object subNodes = HttpRuntime.Cache["TopNavRootNodes" + nodeKey];
if (subNodes != null && subNodes is SiteMapNodeCollection)
return ((SiteMapNodeCollection)subNodes);

lock (this)
{
//Two options available here.
//1. Reload from the list when cache expires in case that is needed
//Commenting out because the top node should decide if we are going to get the tree from cache, not subnodes
//if (String.Compare(ConfigurationManager.AppSettings["ReloadTopNavOnCacheExpiry"], "true", true) == 1)
//{
// rootNode = null;
// LoadTopNavigationFromList();
//}

//Else iterate through the nodes and find the children of this node
for (int i = 0; i < childParentRelationship.Count; i++)
{
string nKey = ((DictionaryEntry)childParentRelationship[i]).Key as string;

//if this is a child
if (nodeKey == nKey)
{
//Get the child from the arraylist
PortalSiteMapNode child = (PortalSiteMapNode)(((DictionaryEntry)childParentRelationship[i]).Value);

if (child != null)
{
children.Add(child as PortalSiteMapNode);
}
else
{
throw new Exception("ArrayLists not in sync.");
}
}
}
//Add the children to the cache
HttpRuntime.Cache["TopNavRootNodes" + nodeKey] = children;
}
}
}
catch (Exception ex)
{
ExceptionManager.Publish(ex);

//return empty site node collection
return new SiteMapNodeCollection();
}

return children;
}
}
}



Here are the settings in the Web.config file for the Web Application.

1. This goes in the providers section.
<add name="CustomTopNavProvider" description="Custom provider for top navigation in Portal Usage pages" type="CompanyXX.MOSS.Branding.CustomProviders.Navigation.CustomTopNavProvider, CompanyXX.MOSS.Branding.CustomProviders, Version=1.0.0.0, Culture=neutral, PublicKeyToken=a5d522bbe3d8f31c" NavigationType="Combined" EncodeOutput="true" />

2. In the appSettings section, add the following entries.
<add key="CompanyXXRootSite" value="http://www.yoursite.com/" />
<add key="TopNavigationListName" value="TopNavList" />
<add key="CurrentLeftNavigationListName" value="CurrentLeftNavList" />
<add key="StaticLeftNavigationListName" value="StaticLeftNavList" />
<add key="NavigationListStartFolderName" value="Group1" />
<add key="UrlLink" value="Url Link" />
<add key="UrlDescription" value="Url Description" />
<add key="NewWindow" value="Open New Window" />
<add key="UrlAudience" value="Url Audience" />
<add key="ItemOrder" value="Item Order" />




I have used this TopNavProvider to build the navigation for a MOSS intranet with ~4000 users, as well as an MOSS internet facing site with ~1.5 million visitors a month. Enjoy!!


I also created another custom navigation provider that reads the current navigation for every site from a similar list on that site and displays that somewhere else on that page (left or right navigation).

Stsadm access denied on Windows Server 2008

I recently ran into a situation where I encountered Access Denied errors when attempting to run stsadm on a dev Windows Server 2008 Web Edition. I checked to see if the user account I was using was a local administrator on the server and it was.

I looked around some more and I was not sure what was causing the problem. Then my friend suggested that we look into the User Account Control settings and those were enabled to help "protect" the server. Turning those off allowed me to run stsadm from the command line.

The User Account Control (UAC) is found under Control Panel --> User Accounts --> Turn User Account Control on or off.

Rocky Mountain User Group Presentation

As promised, here are the files from my presentation on the new release of the SharePoint PNP guidance on Tuesday night. Thank you all for coming, all the references should be in these files. 

If you have any questions, please give me a shout.

Tuesday, November 11, 2008

SharePoint PNP Guidance is Live

The SharePoint guidance which focuses on WSS went live last week. This guidance provides architects and developers best practices on how to:

-- Make architectural decisions about feature factoring, packaging, and the appropriate usage of design patterns.
-- Determine design tradeoffs for common decisions many developers encounter, such as when to use SharePoint lists or a database to store information.
-- Design for testability, create unit tests, and run continuous integration.
-- Set up different environments including the development, build, test, staging, and production environments.
-- Manage the application life cycle through development, test, deployment, and upgrading.

This is really useful and I have been using it on a recent SharePoint extranet project I am doing. Be sure to take a look. You can find more information about it on Blaine's blog post here. You can also check out the content on MSDN here. I will be presenting a session on this at the Rocky Mountain user group next week with John Daniels who was very involved in this project, so anyone in the area please plan on attending to get more details.