Recently, I’ve stumbled over a problem with a Content breadcrumb navigation. Content breadcrumb navigation is a set of hyperlinks that enables site users to quickly navigate up the hierarchy of sites within a site collection.
The problem was that in the edit mode the link with the item title (for example, like the Test post on image) sometimes had a wrong url. Content breadcrumb navigation is a SiteMapPath control, which is rendered based upon site map nodes, provided by a SiteMapProvider. An obvious way to solve the issue is to find the problematic site map node in the tree and try to modify the node in the memory, i.e. to replace its url or the entire node.
I have got a fully customized edit page for my list (for each content type, actually); in other words, I got access to the executable code (so-called, code-behind) and the markup of the aspx-page. That is the main requirement for the successful resolution of the issue.
First of all, I found a place where SiteMapPath had been declared in default.master:
<asp:ContentPlaceHolder id="PlaceHolderTitleBreadcrumb" runat="server">
<asp:SiteMapPath SiteMapProvider="SPContentMapProvider" id="ContentMap" SkipLinkText="" NodeStyle-CssClass="ms-sitemapdirectional" runat="server"/>
</asp:ContentPlaceHolder>
Then I copied it to my edit aspx-page with the appropriate changes:
<asp:Content ID="ContentBreadcrumb" ContentPlaceHolderID="PlaceHolderTitleBreadcrumb" runat="server">
<asp:SiteMapPath SiteMapProvider="SPContentMapProvider" id="SiteMapPathContentMap" SkipLinkText="" NodeStyle-CssClass="ms-sitemapdirectional"
runat="server" />
</asp:Content>
From that point, I could easily manipulate the SiteMapPath control in the code-behind of aspx-page.
The standard way to programmatically modify site map nodes in the memory is to hande the SiteMap.SiteMapResolveevent on an ASP.Net web page. This way is described in msdn http://msdn.microsoft.com/en-us/library/ms178425%28v=VS.85%29.aspx. The following listing demonstrates how to connect to the required SiteMapResolve event. Note that our target SiteMapProvider is SPContentMapProvider.
protected void Page_Load(object sender, EventArgs e)
{
Microsoft.SharePoint.Navigation.SPContentMapProvider prov =
(Microsoft.SharePoint.Navigation.SPContentMapProvider)SiteMap.Providers["SPContentMapProvider"];
if (prov != null)
prov.SiteMapResolve += new SiteMapResolveEventHandler(prov_SiteMapResolve);
}
In the prov_SiteMapResolve handler, I tried using two variants of code. The first variant is to set the required url to a certain SiteMapNode, which turns into a link with the item title:
SiteMapNode prov_SiteMapResolve(object sender, SiteMapResolveEventArgs e)
{
SiteMapNode currentNode = e.Provider.CurrentNode; // in our case current node is 'Edit Item'
SiteMapNode parentNode = currentNode.ParentNode; // parent node is 'Test post'
if (parentNode != null && parentNode.Title.Equals(SPContext.Current.ListItem.Title, StringComparison.OrdinalIgnoreCase))
{
bool originalReadOnly = parentNode.ReadOnly;
parentNode.ReadOnly = false;
parentNode.Url = "microsoft.com";
parentNode.ReadOnly = originalReadOnly;
}
return currentNode;
}
This variant passes through without errors, but at the same time produces no result. The problematic hyperlink remains with a wrong url.
Another variant tries to replace current node with a new SiteMapNode object:
SiteMapNode prov_SiteMapResolve(object sender, SiteMapResolveEventArgs e)
{
SiteMapNode currentNode = e.Provider.CurrentNode; // in our case current node is 'Edit Item'
SiteMapNode parentNode = currentNode.ParentNode; // parent node is 'Test post'
if (parentNode != null && parentNode.Title.Equals(SPContext.Current.ListItem.Title, StringComparison.OrdinalIgnoreCase))
{
// Clone the current node and all of its relevant parents. This
// returns a site map node that can then be walked and modified.
// Since the cloned nodes are separate from the underlying
// site navigation structure, the changes that are made do not
// effect the overall site navigation structure.
currentNode = currentNode.Clone(true);
parentNode = currentNode.ParentNode;
parentNode.Url = "microsoft.com";
}
return currentNode;
}
This code throws an exception – “Unable to cast object of type ‘System.Web.SiteMapNode’ to type ‘Microsoft.SharePoint.Navigation.SPSiteMapNode’.” Which is quite expected, as SPContentMapProvider indeed provides a tree of SPSiteMapNodes and not SiteMapNodes. Moreover, there is no way to go around this obstacle, as SPSiteMapNode is an internal class, so we cannot create instances of it. So, I must conclude that the use of the SiteMapResolve event, which successfully works for ASP.Net, doesn’t work for SharePoint.
But let’s try a different way. The SiteMapPath control has these two events: ItemCreated and ItemDataBound. Theoretically, they could be used for changing the url of nodes. I’ve tried using them both as follows:
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
SiteMapPathContentMap.ItemCreated += new SiteMapNodeItemEventHandler(SiteMapPathContentMap_ItemCreated);
//SiteMapPathContentMap.ItemDataBound += new SiteMapNodeItemEventHandler(SiteMapPathContentMap_ItemDataBound);
}
void SiteMapPathContentMap_ItemCreated(object sender, SiteMapNodeItemEventArgs e)
{
if (e.Item.ItemType == SiteMapNodeItemType.PathSeparator)
return;
if (e.Item.SiteMapNode.Title.Equals(SPContext.Current.ListItem.Title, StringComparison.OrdinalIgnoreCase))
{
bool originalReadOnly = e.Item.SiteMapNode.ReadOnly;
e.Item.SiteMapNode.ReadOnly = false;
e.Item.SiteMapNode.Url = "microsoft.com";
e.Item.SiteMapNode.ReadOnly = originalReadOnly;
}
}
Unfortunately, that produces no result too. e.Item.SiteMapNode.Url does change indeed, but the rendered hyperlink still remains with a wrong url. What could cause that? Let’s take a look inside SiteMapPath by means of Reflector. SiteMapPath contains the CreateItem method, which is called for each site map node, and which creates SiteMapNodeItem.
private SiteMapNodeItem CreateItem(int itemIndex, SiteMapNodeItemType itemType, SiteMapNode node)
{
SiteMapNodeItem item = new SiteMapNodeItem(itemIndex, itemType);
int index = (this.PathDirection == PathDirection.CurrentToRoot) ? 0 : -1;
SiteMapNodeItemEventArgs e = new SiteMapNodeItemEventArgs(item);
item.SiteMapNode = node;
this.InitializeItem(item);
this.OnItemCreated(e);
this.Controls.AddAt(index, item);
item.DataBind();
this.OnItemDataBound(e);
item.SiteMapNode = null;
item.EnableViewState = false;
return item;
}
The ItemCreated and ItemDataBound events are fired inside this method. Note that ItemCreated activates immediately after calling InitializeItem. Let’s take a look at InitializeItem.
protected virtual void InitializeItem(SiteMapNodeItem item)
{
// some code is skipped
SiteMapNode siteMapNode = item.SiteMapNode;
// some code is skipped
if (itemType == SiteMapNodeItemType.PathSeparator)
{
Literal child = new Literal
{
Mode = LiteralMode.Encode,
Text = this.PathSeparator
};
item.Controls.Add(child);
item.ApplyStyle(s);
}
else if ((itemType == SiteMapNodeItemType.Current) && !this.RenderCurrentNodeAsLink)
{
Literal literal2 = new Literal
{
Mode = LiteralMode.Encode,
Text = siteMapNode.Title
};
item.Controls.Add(literal2);
item.ApplyStyle(s);
}
else
{
HyperLink link = new HyperLink(); // (*)
if ((s != null) && s.IsSet(0x2000))
{
link.Font.Underline = s.Font.Underline;
}
link.EnableTheming = false;
link.Enabled = this.Enabled;
if (siteMapNode.Url.StartsWith(@"\\", StringComparison.Ordinal))
{
link.NavigateUrl = base.ResolveClientUrl(HttpUtility.UrlPathEncode(siteMapNode.Url));
}
else
{
link.NavigateUrl = (this.Context != null) ?
this.Context.Response.ApplyAppPathModifier(base.ResolveClientUrl(HttpUtility.UrlPathEncode(siteMapNode.Url))) : siteMapNode.Url;
}
link.Text = HttpUtility.HtmlEncode(siteMapNode.Title);
if (this.ShowToolTips)
{
link.ToolTip = siteMapNode.Description;
}
item.Controls.Add(link);
link.ApplyStyle(s);
}
}
As we can see, InitializeItem creates and fills out a HyperLink object (see the line marked as (*)) that corresponds to the passed SiteMapNodeItem and, consequently, to the linked SiteMapNode. After the creation and initialization, the HyperLink object is added to the control tree of SiteMapNodeItem. Thereby, the ItemCreated event is fired immediately after all the required controls, including hyperlinks, are already created and added to the control tree. That means that all our changes of SiteMapNode inside the ItemCreated handler are doomed to have no effect, just because by that time all the hyperlinks are already created and are not going to be modified.
How to get over this problem? I’ve created an enhanced version of the SiteMapPath control. This derived class has an ItemCreating event, which is fired immediately after SiteMapNodeItem is created but before InitializeItem is called. Here is the control:
public class MySiteMapPath : SiteMapPath
{
private static readonly object _eventItemCreating = new object();
public event SiteMapNodeItemEventHandler ItemCreating
{
add { Events.AddHandler(_eventItemCreating, value); }
remove { Events.RemoveHandler(_eventItemCreating, value); }
}
protected virtual void OnItemCreating(SiteMapNodeItemEventArgs e)
{
SiteMapNodeItemEventHandler handler = (SiteMapNodeItemEventHandler)base.Events[_eventItemCreating];
if (handler != null)
handler(this, e);
}
protected override void InitializeItem(SiteMapNodeItem item)
{
OnItemCreating(new SiteMapNodeItemEventArgs(item));
base.InitializeItem(item);
}
}
In the handler of ItemCreating event, we can freely change site map nodes, and the hyperlinks will reflect those changes. Here is an example of the use of the MySiteMapPath in code-behind:
public MySiteMapPath SiteMapPathContentMap;
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
if(SiteMapPathContentMap != null)
SiteMapPathContentMap.ItemCreating += new SiteMapNodeItemEventHandler(SiteMapPathContentMap_ItemCreating);
}
void SiteMapPathContentMap_ItemCreating(object sender, SiteMapNodeItemEventArgs e)
{
if (e.Item.ItemType == SiteMapNodeItemType.PathSeparator)
return;
if (e.Item.SiteMapNode.Title.Equals(SPContext.Current.ListItem.Title, StringComparison.OrdinalIgnoreCase))
{
bool originalReadOnly = e.Item.SiteMapNode.ReadOnly;
e.Item.SiteMapNode.ReadOnly = false;
e.Item.SiteMapNode.Url = "microsoft.com";
e.Item.SiteMapNode.ReadOnly = originalReadOnly;
}
}
This is the murkup that has to be in aspx-page:
<asp:Content ID="ContentBreadcrumb" ContentPlaceHolderID="PlaceHolderTitleBreadcrumb" runat="server">
<myNamespace:MySiteMapPath SiteMapProvider="SPContentMapProvider" id="SiteMapPathContentMap" SkipLinkText=""
NodeStyle-CssClass="ms-sitemapdirectional" runat="server" />
</asp:Content>
So, if you want to change certain properties of a content breadcrumb navigation hyperlink, just declare MySiteMapPath and handle its ItemCreating event in the appropriate way.