hi steven,
thanks for the tip.
just for anyone else who is interested, i've included my implementation
below. it consists of a derived XmlSiteMapPRovider as suggested by steve,
and an accompanying web admin page, allowing view/edit/add/delete of the
sitemap nodes. it assumes unique titles in all pages, which may not be true
for other apps, the code could easily be modified to specify a key or other
attribute. i haven't time to make it totally user friendly, i.e. removing
my own functions etc., but it shouldn't be too hard to adapt. i'm very
surprised this functionality wasn't bundled, surely it is a popular feature
to programitcally change the web.sitemap file?
anyway, here is web admin page aspx code:
<asp:Content ID="Content1" ContentPlaceHolderID="ContentPlaceHolder1"
runat="Server">
<h2>
Site Map / Navigation / Permissions</h2>
<div class="FloatRightPanel">
<h3>
<asp:Label ID="lblAddEdit" runat="server"
Text="Add"></asp:Label>
URL: </h3>
<br />
<strong>Title:</strong>
<asp:TextBox ID="txtTitle" runat="server" ValidationGroup="User"
Columns="40"></asp:TextBox>
<asp:RequiredFieldValidator ID="RequiredFieldValidator1"
runat="server" ControlToValidate="txtTitle"
Display="Dynamic" ErrorMessage="* Required"
SetFocusOnError="True"
ValidationGroup="User"></asp:RequiredFieldValidator><br />
<strong>URL: </strong> <asp:TextBox ID="txtUrl" runat="server"
ValidationGroup="User"
Columns="40">~/</asp:TextBox>
<br />
<strong>Parent Node:</strong>
<asp
ropDownList ID="ddlNodes" runat="server" DataTextField="Title"
DataValueField="Title">
</asp
ropDownList><br />
<strong>Roles: </strong><asp:CheckBoxList ID="chkRoles"
runat="server" RepeatDirection="Horizontal"
RepeatColumns="2">
</asp:CheckBoxList>
<br />
<asp:Button ID="btnSave" runat="server" Text="Save"
ValidationGroup="User" OnClick="btnSave_Click" />
<asp:Button ID="btnCancel" runat="server" Text="Cancel"
OnClick="btnCancel_Click" />
<asp:Button ID="btnDelete" runat="server" Text="Delete"
OnClick="btnDelete_Click"
CausesValidation="False" />
</div>
<asp:GridView Style="float: left" CssClass="grid"
HeaderStyle-CssClass="gH" AlternatingRowStyle-CssClass="gA"
RowStyle-CssClass="gI" SelectedRowStyle-CssClass="gS" ID="GridView1"
runat="server"
AutoGenerateColumns="False"
OnSelectedIndexChanged="GridView1_SelectedIndexChanged">
<Columns>
<asp:CommandField ShowSelectButton="True" />
<asp:BoundField DataField="Url" HeaderText="URL" ReadOnly="True"
SortExpression="Url" />
<asp:BoundField DataField="Title" HeaderText="Title"
ReadOnly="True" SortExpression="Title" />
</Columns>
<RowStyle CssClass="gI" />
<SelectedRowStyle CssClass="gS" />
<HeaderStyle CssClass="gH" />
<AlternatingRowStyle CssClass="gA" />
</asp:GridView>
</asp:Content>
web admin page code behind:
using System;
using System.Data;
using System.Collections.Generic;
using System.ComponentModel;
using System.Configuration;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Configuration;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
public partial class System_Administration_SiteMap : System.Web.UI.Page
{
private SiteMapDataSource SiteMap;
protected void Page_Load(object sender, EventArgs e)
{
this.SiteMap = (this.Master as DefaultMaster).SiteMapDs; // use the
master page SiteMapDataSource rather than recreate multiple ones for child
pages etc.
if(!IsPostBack)
{
this.ddlNodes.DataSource = this.GridView1.DataSource =
this.SiteMap.Provider.RootNode.GetAllNodes();
this.GridView1.DataBind();
this.ddlNodes.DataBind();
this.ddlNodes.Items.Insert(0, this.SiteMap.Provider.RootNode.Title);
this.chkRoles.DataSource = Roles.GetAllRoles();
this.chkRoles.DataBind();
this.btnDelete.Visible = false;
}
}
private SiteMapNode GetSelectedGridViewNode()
{
string SelectedTitle = this.GridView1.SelectedRow.Cells[2].Text;
foreach(SiteMapNode child in this.SiteMap.Provider.RootNode.GetAllNodes())
if(child.Title == SelectedTitle)
return child;
return null;
}
private SiteMapNode GetSelectedDdlParentNode()
{
foreach(SiteMapNode node in this.SiteMap.Provider.RootNode.GetAllNodes())
if(node.Title == this.ddlNodes.SelectedValue)
return node;
// if the code falls out to here, it must be the root node selected
return this.SiteMap.Provider.RootNode;
}
protected void GridView1_SelectedIndexChanged(object sender, EventArgs e)
{
// find the node and init the edit controls
this.lblAddEdit.Text = "Edit"; // switch to 'edit' mode
SiteMapNode selected = GetSelectedGridViewNode();
if(selected == null)
{
WebUtil.JavascriptAlert("Error: node not found", this);
return;
}
this.txtTitle.Text = selected.Title;
if(!String.IsNullOrEmpty(selected.Url)) // get the correct
(unmapped/changed) url by reading the xml file directly
this.txtUrl.Text = (this.SiteMap.Provider as
EditableXmlSiteMapProvider).GetActualUrl(selected.Title);
else
this.txtUrl.Text = "";
foreach(ListItem item in chkRoles.Items)
if(selected.Roles.Contains(item.Text))
item.Selected = true;
this.ddlNodes.SelectedValue = selected.ParentNode.Title;
this.btnDelete.Visible = true;
}
protected void btnSave_Click(object sender, EventArgs e)
{
string roles = "";
foreach(ListItem item in chkRoles.Items)
if(item.Selected)
roles += item.Text + ",";
roles = roles.TrimEnd(',');
if(this.lblAddEdit.Text == "Add")
(this.SiteMap.Provider as
EditableXmlSiteMapProvider).AddNode(GetSelectedDdlParentNode().Title,
this.txtTitle.Text.Trim(), this.txtUrl.Text.Trim(), roles);
else
(this.SiteMap.Provider as
EditableXmlSiteMapProvider).UpdateNode(GetSelectedGridViewNode().Title,
this.ddlNodes.SelectedValue, this.txtTitle.Text.Trim(),
this.txtUrl.Text.Trim(), roles);
WebUtil.JavascriptAlertRedirect("Record saved", Request.Url.PathAndQuery);
}
protected void btnDelete_Click(object sender, EventArgs e)
{
(this.SiteMap.Provider as
EditableXmlSiteMapProvider).DeleteNode(GetSelectedGridViewNode().Title);
WebUtil.JavascriptAlertRedirect("Record deleted",
Request.Url.PathAndQuery);
}
protected void btnCancel_Click(object sender, EventArgs e)
{
Response.Redirect(Request.Url.PathAndQuery);
}
}
XmlSiteMapProvider code:
using System;
using System.Data;
using System.Configuration;
using System.IO;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Configuration;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Xml;
/// <summary>
/// Extends the XmlSiteMapProvider class with edit functionality
/// Requires all siteMapNodes to have unique titles.
/// </summary>
public class EditableXmlSiteMapProvider : XmlSiteMapProvider
{
public EditableXmlSiteMapProvider()
{
}
public void AddNode(string parentTitle, string title, string url, string
roles)
{
XmlDocument doc = LoadXmlDoc();
XmlElement parent = FindNodeByTitle(doc, parentTitle);
XmlElement newChild = doc.CreateElement("siteMapNode",
"
http://schemas.microsoft.com/AspNet/SiteMap-File-1.0");
newChild.SetAttribute("url", url); // url must go in lower case to get
xpath to work
newChild.SetAttribute("title", title); // url must go in lower case to get
xpath to work
newChild.SetAttribute("roles", roles);
parent.AppendChild(newChild);
SaveXmlDoc(doc);
}
public void UpdateNode(string originalTitle, string newParentTitle, string
title, string url, string roles)
{
XmlDocument doc = LoadXmlDoc();
XmlElement node = FindNodeByTitle(doc, title);
node.SetAttribute("url", url); // url must go in lower case to get xpath
to work
node.SetAttribute("title", title); // url must go in lower case to get
xpath to work
node.SetAttribute("roles", roles);
// check if the parent has changed
if(node.ParentNode.Attributes["title"].Value != newParentTitle)
{
node.ParentNode.RemoveChild(node);
// find the new parent
XmlElement newParent = FindNodeByTitle(doc, newParentTitle);
newParent.AppendChild(node);
}
SaveXmlDoc(doc);
}
public void DeleteNode(string title)
{
XmlDocument doc = LoadXmlDoc();
XmlElement node = FindNodeByTitle(doc, title);
node.ParentNode.RemoveChild(node);
SaveXmlDoc(doc);
}
private XmlDocument LoadXmlDoc()
{
XmlDocument doc = new XmlDocument();
doc.Load(HttpContext.Current.Server.MapPath(FilePath));
return doc;
}
private void SaveXmlDoc(XmlDocument doc)
{
string AbsPath = HttpContext.Current.Server.MapPath(FilePath);
try
{
doc.Save(AbsPath);
}
catch(UnauthorizedAccessException ex)
{
try
{
// try to remove 'read-only' attribute on the file.
WebUtil.RemoveReadOnlyFileAttribute(AbsPath);
doc.Save(AbsPath);
}
catch
{
throw ex; // throw the original exception
}
}
base.Clear();
base.BuildSiteMap();
}
private XmlElement FindNodeByTitle(XmlDocument doc, string title)
{
string xPath = String.Format("//*[@title='{0}']", title);
XmlElement node = doc.SelectSingleNode(xPath) as XmlElement;
if(node == null)
throw new Exception("Node not found with title: " + title);
return node;
}
/// <summary>
/// The built in SiteMapNode.Url property gives a different value to the
actual web.sitemap value,
/// it is a mapped value that changes depending on the v.dir of the running
web site.
/// This method reads the xml attribute direct from the web.sitemap file.
/// </summary>
public string GetActualUrl(string title)
{
return this.FindNodeByTitle(LoadXmlDoc(), title).Attributes["url"].Value;
}
public static string FilePath
{
get
{
// if anyone gets a nicer method to read the web siteMapFile attribute,
please post it. i tried using System.Web.Configuration but i couldn't get it
working. also, the site breaks when running off VS web server because of
'cannot read IIS metabase' errors.
string webConfigText =
File.ReadAllText(HttpContext.Current.Server.MapPath("~/web.config"));
Match m = Regex.Match(webConfigText, @"siteMapFile=""(.*?)""",
RegexOptions.IgnoreCase | RegexOptions.Multiline);
if(!m.Success)
return "/web.sitemap"; // default value. otherwise we could throw new
Exception("web.config does not contain a siteMapFile element");
else
return m.Groups[1].Captures[0].Value;
}
}
}
and finally, the web.config settings:
<siteMap defaultProvider="EditableXmlSiteMapProvider" enabled="true">
<providers>
<add name="EditableXmlSiteMapProvider" description="Default SiteMap
provider." type="EditableXmlSiteMapProvider"
siteMapFile="~/App_Data/web.sitemap" securityTrimmingEnabled="true"/>
</providers>
</siteMap>