Tuesday, November 24, 2009

Robust ASP.NET navigation - Fluent interface for site map generation

ASP.Net provides classes that is suitable for holding current sitemap structure, displaying it, using default controls, searching for current selected node, but not for building it in a elegant way, at least at WCSF. The code in MSDN article is pretty simple
SiteMapNodeInfo moduleNode = new SiteMapNodeInfo("Customers", "~/Customers/ApproveCustomerView.aspx", "Approve Customer");
siteMapBuilderService.AddNode(moduleNode);
siteMapBuilderService.AddNode(moduleNode, parentNode);
siteMapBuilderService.AddNode(moduleNode, 100);
siteMapBuilderService.AddNode(moduleNode, “AllowViewModule1”);

But when you have numerous of pages, sitemap building code can turn into crap like this:

CustomSiteMapNodeInfo HomeNode = new CustomSiteMapNodeInfo("Home", "~/Default.aspx", "Home", "Home", true);
SiteMapBuilderService.AddNode(HomeNode);

SiteMapBuilderService.RootNode.Url = "~/Default.aspx";
SiteMapBuilderService.RootNode.Title = "FOO-BAR";

CustomSiteMapNodeInfo CustomersNode = new CustomSiteMapNodeInfo("Customers", "#Customers_List", "Customers", true);
SiteMapBuilderService.AddNode(CustomersNode);

SiteMapBuilderService.AddNode(SiteMapBuilderServiceHelper.CreateLocalizableNode("Customers", "CustomersList", true, true), SalesNode);
CustomSiteMapNodeInfo TransactionsNode = SiteMapBuilderServiceHelper.CreateLocalizableNode("Transactions", "TransactionsList", true, true);
SiteMapBuilderService.AddNode(TransactionsNode, CustomersNode);

CustomSiteMapNodeInfo IncomeNode = SiteMapBuilderServiceHelper.CreateLocalizableNode("Income", "IncomeList", true, true);
SiteMapBuilderService.AddNode(IncomeNode , CustomersNode);

SiteMapBuilderService.AddNode(new CustomSiteMapNodeInfo("Separator-1", String.Empty, SEPARATOR, false, true), CustomersNode);

CustomSiteMapNodeInfo OutcomeNode = SiteMapBuilderServiceHelper.CreateLocalizableNode("Outcome", "OutcomeList", true, true);
SiteMapBuilderService.AddNode(OutcomeNode , CustomersNode);

Let's try to create API that will remove all unnecessary information form the code, automatize localization stuff, and make those "true, true" arguments more descriptive.

My target would be code like this:

var builder = new SiteMapBuilder();
builder.Root(new DefaultUrl());
builder.Module("Customers")
.Page(new CustomersUrl())
.Page(new TransactionsUrl())
.Page(new IncomeUrl())
.Separator()
.Page(new OutcomeUrl()).WithImage();
SiteMapBuilderService.AddNode(builder.GetRootNode());

Site map builder features:
  • Create "Modules" - a top level menu items"
  • Add "Pages" to modules - a sub-menu items
  • Add "Separators" - fake menu items that will be rendered to horizontal line
  • Set attributes to Page, like adding an image
First step is pretty simple, setting a root node will produce a node without child nodes.

[Test]
public void BuilderWithSetRootReturnsRootNode()
{
var Builder = new SiteMapBuilder();
var FooUrl = new FooUrl(String.Empty);
Builder.Root(FooUrl);

Assert.That(Builder.GetRootNode().Url, Is.SameAs(FooUrl));
Assert.That(Builder.GetRootNode().ChildNodes, Is.Empty);
}

The code is very simple. Note that we have moved away a knowledge about SiteMapNode construction itself to separate factory, to keep builder responsibilities clean.

public class SiteMapBuilder
{
private readonly SiteNodeFactory _SiteNodeFactory = new DefaultSiteNodeFactory();
private IUrl _RootUrl;

public SiteMapNode GetRootNode()
{
return _SiteNodeFactory.CreateModuleNode(_RootUrl);
}

public void Root(IUrl Url)
{
_RootUrl = Url;
}
}

Next - module registration feature. API consumer can register several top menu items, that should produce root node with some child nodes.

The test:

[Test]
public void BuilderSeveralModulesInRoot()
{
var Builder = new SiteMapBuilder();
var FooUrl = new FooUrl(String.Empty);
Builder.Root(FooUrl);

Builder.Module(new FooUrl("a"));
Builder.Module(new FooUrl("b"));

Assert.That(Builder.GetRootNode().ChildNodes[0].Url, Is.EqualTo(new FooUrl("a")));
Assert.That(Builder.GetRootNode().ChildNodes[1].Url, Is.EqualTo(new FooUrl("b")));
}

Implementetion: a list of module's urls are stored in builder and waiting to be transformed into nodes.

public class SiteMapBuilder
{
private readonly List _Modules = new List();
private readonly SiteNodeFactory _SiteNodeFactory = new DefaultSiteNodeFactory();
private IUrl _RootUrl;

public SiteMapNode GetRootNode()
{
var ChildNodes = _Modules.Select(Q => _SiteNodeFactory.CreateNode(Q)).ToArray();
return _SiteNodeFactory.CreateModuleNode(ChildNodes ,_RootUrl);
}

public void Module(IUrl Url)
{
_Modules.Add(Url);
}

public void Root(IUrl Url)
{
_RootUrl = Url;
}
}

Ok, it's good enough for this moment, you can set root url and add several top-level nodes to the site map. Only page support left, and here is "fluent" trick is. Let's add "public SiteMapBuilder Page(IUrl)" method, and return "this" reference from it. Consumer code will look like:

builder
.Module("Customers")
.Page(new CustomersUrl())
.Page(new TransactionsUrl())
.Page(new IncomeUrl())
.Module("Second module")
.Page(new CustomersUrl())
.Page(new TransactionsUrl())
.Page(new IncomeUrl());

Bad idea, auto-formatter will turn this code into flat one, without different indent, and builder class itself will be overloaded with various page construction parameters. Let's move it to ModuleBuilder class.

public class SiteMapBuilder
{
private readonly List _Modules = new List();
private readonly SiteNodeFactory _SiteNodeFactory = new DefaultSiteNodeFactory();
private IUrl _RootUrl;

public SiteMapNode GetRootNode()
{
var ChildNodes = _Modules.Select(Q => Q.GetNode()).ToArray();
return _SiteNodeFactory.CreateModuleNode(ChildNodes ,_RootUrl);
}

public void Module(IUrl Url)
{
var ModuleBuilder = new ModuleBuilder(Url, _SiteNodeFactory);
_Modules.Add(ModuleBuilder);
return ModuleBuilder;
}

public void Root(IUrl Url)
{
_RootUrl = Url;
}
}

public class ModuleMenuBuilder : IModuleMenuBuilder
{
private readonly IUrl _Url;
private readonly List _Pages = new List();

private readonly SiteNodeFactory _SiteNodeFactory;

public ModuleMenuBuilder(IUrl Url, SiteNodeFactory SiteNodeFactory)
{
_Url = Url;
_SiteNodeFactory = SiteNodeFactory;
}

public SiteMapNode GetNode()
{
return _SiteNodeFactory.CreateModuleNode(_Pages.ToArray(), _Url);
}

public ModuleMenuBuilder Page(IUrl Url)
{
_Pages.Add(_SiteNodeFactory.CreatePageNode(Url))
return this;
}

public ModuleMenuBuilder Separator()
{
_Pages.Add(_SiteNodeFactory.Separator());
return this;
}
}

It’s look like final version. Here is a usage example:

builder.Module("Customers")
.Page(new CustomersUrl())
.Page(new TransactionsUrl())
.Page(new IncomeUrl());
builder.Module("Second module")
.Page(new CustomersUrl())
.Page(new TransactionsUrl())
.Page(new IncomeUrl());

No comments:

Post a Comment