Thursday, November 20, 2008

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.

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 retreivalprotected SiteMapNodeCollection siteMapNodeColl;

//protected ArrayList childParentRelationship;

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

/// <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 oncelock (this){if (rootNode != null){return;}else{//Initialiaze for the first timeSPSite rootSite = null;SPWeb rootWeb = null;SPList topnavList = null;

try{//Clear the top level nodes and the relationshipstopLevelNodes.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 collectionrootNode = (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"]];

}catch (Exception ex){//There was a problem opening the site or the list.ExceptionManager.Publish(ex);}finally{//Dispose of the objectsif (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 folderSPQuery 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 PortalSiteMapNodesSPListItemCollection 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;

//Order the nodestry{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 folderif (subitem.Folder != null)BuildListNodes(currentWeb, subitem.Folder, prtlWebSiteMapNode, psmn, false);}

//Copy nodes in the right orderforeach (object portalSiteMapNode in orderedNodes.Values){//Add the node to the different collectionsif (rootLevel)topLevelNodes.Add(portalSiteMapNode);

/// <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 nodeSiteMapNodeCollection children = new SiteMapNodeCollection();

try{//If an absolute rootnode, then add the top level children which are the same for every site collectionif (node == node.RootNode){//Serve it from cache if possible.//TODO: See if better way to do cachingobject 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 neededif (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 abovefor (int i = 0; i < topLevelNodes.Count; i++){children.Add(topLevelNodes[i] as PortalSiteMapNode);}

//Add them to the cacheHttpRuntime.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 thereobject 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 nodefor (int i = 0; i < childParentRelationship.Count; i++){string nKey = ((DictionaryEntry)childParentRelationship[i]).Key as string;

//if this is a childif (nodeKey == nKey){//Get the child from the arraylistPortalSiteMapNode 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 cacheHttpRuntime.Cache["TopNavRootNodes" + nodeKey] = children;}}}catch (Exception ex){ExceptionManager.Publish(ex);

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).

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.

Tuesday, November 11, 2008

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.