Can MVC SiteMapProvider dynamicNode be used on 1st Level? - asp.net-mvc

I've been messing with MVC SiteMapProvider for a little while and love it. I'm building an ecommerce site and it has worked really well so far during my development.
The one issue I can't seem to wrap my head around is how to get dynamicNode to work on first level.
Something like this:
www.mysite.com/{type}/{category}/{filter}
There are only 3 types so for now I just have 3 controllers named after the type and they all use the same logic and viewModels which is not an ideal set up for maintainability down the line. My routeConfig includes 3 routes like this.
routes.MapRoute(
name: "Hardscape",
url: "hardscape-products/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", category = UrlParameter.Optional, filter = UrlParameter.Optional},
namespaces: new[] { "MyApp.Web.Controllers" }
);
routes.MapRoute(
name: "Masonry",
url: "masonry-products/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", category = UrlParameter.Optional, filter = UrlParameter.Optional},
namespaces: new[] { "MyApp.Web.Controllers" }
);
routes.MapRoute(
name: "Landscape",
url: "landscape-products/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", category = UrlParameter.Optional, filter = UrlParameter.Optional},
namespaces: new[] { "MyApp.Web.Controllers" }
);
I've tried something like this but it returns 404.
routes.MapRoute(
name: "Products",
url: "{productType}/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", productType = UrlParameter.Optional, category = UrlParameter.Optional, filter = UrlParameter.Optional},
namespaces: new[] { "MyApp.Web.Controllers" }
);
I've been able to generate my nodes in the sitemap and menu using dynamicNode for my category and filter parameter. Just having trouble with first level when I'm not naming the first level statically
masonry-products/ vs. {productType}/
Please let me know if you have a solution. Hopefully NightOwl can chime in.

The routing framework of .NET is very flexible.
For this situation, you could just use a constraint for the types. There are 2 ways:
Use a RegEx.
Implement a custom class.
The first option wouldn't be so bad if you aren't expecting a lot of changes:
routes.MapRoute(
name: "Products",
url: "{productType}/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", category = UrlParameter.Optional, filter = UrlParameter.Optional},
constraints: new { productType = #"hardscape-products|masonry-products|landscape-products" },
namespaces: new[] { "MyApp.Web.Controllers" }
);
The second option is more dynamic:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Routing;
public class ProductTypeConstraint : IRouteConstraint
{
private object synclock = new object();
public bool Match
(
HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection
)
{
return GetProductTypes(httpContext).Contains(values[parameterName]);
}
private IEnumerable<string> GetProductTypes(HttpContextBase httpContext)
{
string key = "ProductTypeConstraint_GetProductTypes";
var productTypes = httpContext.Cache[key];
if (productTypes == null)
{
lock (synclock)
{
productTypes = httpContext.Cache[key];
if (productTypes == null)
{
// TODO: Retrieve the list of Product types from the
// database or configuration file here.
productTypes = new List<string>()
{
"hardscape-products",
"masonry-products",
"landscape-products"
};
httpContext.Cache.Insert(
key: key,
value: productTypes,
dependencies: null,
absoluteExpiration: System.Web.Caching.Cache.NoAbsoluteExpiration,
slidingExpiration: TimeSpan.FromMinutes(15),
priority: System.Web.Caching.CacheItemPriority.NotRemovable,
onRemoveCallback: null);
}
}
}
return (IEnumerable<string>)productTypes;
}
}
Caching is necessary here because constraints are hit on every request.
routes.MapRoute(
name: "Products",
url: "{productType}/{category}/{filter}",
defaults: new { controller = "Products", action = "Index", category = UrlParameter.Optional, filter = UrlParameter.Optional},
constraints: new { productType = new ProductTypeConstraint() },
namespaces: new[] { "MyApp.Web.Controllers" }
);
Of course, that is not the only dynamic option. If you really need to just pick any URL of your choosing, like in a CMS, you can inherit RouteBase and drive all of your URLs from the database.
Not sure what this question has to do with dynamic node provider is, though. Nor do I understand what is meant by "first level".
The only thing you really need to do with the dynamic node provider is match the same route values you have in your routes and to provide a key-parent key relationship. There must be a parent key defined in either XML or as a .NET attribute to attach the top level node(s) from the provider on.
Routing
dynamicNode.Controller = "Product";
dynamicNode.Action = "Index";
dynamicNode.RouteValues.Add("productType", "hardscape-products");
dynamicNode.RouteValues.Add("category", "some-category");
dynamicNode.RouteValues.Add("filter", "some-filter");
OR
dynamicNode.Controller = "Product";
dynamicNode.Action = "Index";
dynamicNode.PreservedRouteParameters = new string[] { "productType", "category", "filter" };
OR
Some combination of route values and preserved route parameters that makes sense for your application.
For an explanation of these options, read How to Make MvcSiteMapProvider Remember a User's Position.
Key Matching
// This assumes you have explicitly set a key to "Home"
// in a node outside of the dynamic node provider.
dynamicNode.ParentKey = "Home";
dynamicNode.Key = "Product1";
// This node has the node declared above
// as its parent.
dynamicNode.ParentKey = "Product1";
dynamicNode.Key = "Product1Details";

Solution by OP.
Big thank you to NightOwl888 for a detailed answer which helped me solve this. I had previously followed an MSDN tutorial here which I think confused me regarding the use of constraints.
To summarize, I didn't define my constraint correctly which caused the 404 and all of my other issues with MVCSiteMapProvider. Here's a sample of the working solution.
Route
routes.MapRoute(
name: "Products",
url: "{productType}/{category}/{filter}/{filterAction}/{filterId}",
defaults: new { controller = "Products", action = "Index", productType = UrlParameter.Optional, category = UrlParameter.Optional, filter = UrlParameter.Optional, filterAction = UrlParameter.Optional, filterId = UrlParameter.Optional },
constraints: new { productType = #"building-products|installation-materials|tools" },
namespaces: new[] { "MyApp.Web.Controllers" }
);
XML
<mvcSiteMapNode title="Product Type" dynamicNodeProvider="MyApp.Web.SiteMapProviders.ProductTypeSiteMapProvider, MyApp.Web">
<mvcSiteMapNode title="Category" dynamicNodeProvider="MyApp.Web.SiteMapProviders.CategorySiteMapProvider, MyApp.Web">
<mvcSiteMapNode title="Option" dynamicNodeProvider="MyApp.Web.SiteMapProviders.OptionSiteMapProvider, MyApp.Web" />
<mvcSiteMapNode title="Association" dynamicNodeProvider="MyApp.Web.SiteMapProviders.AssociationSiteMapProvider, MyApp.Web" />
</mvcSiteMapNode>
</mvcSiteMapNode>
The first 2 of 4 DynamicNodes to give you an idea
public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
{
using (var db = new ProductContext())
{
foreach (var productType in db.ProductTypes.ToList())
{
DynamicNode dynamicNode = new DynamicNode();
dynamicNode.Key = productType.Name.ToLower().Replace(" ", "-");
dynamicNode.Title = productType.Name;
dynamicNode.Clickable = false;
yield return dynamicNode;
}
}
}
public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
{
using (var db = new ProductContext())
{
foreach (var category in db.Categories.ToList())
{
DynamicNode dynamicNode = new DynamicNode();
dynamicNode.Key = category.Name.Replace(" ", "");
dynamicNode.Title = category.Name;
dynamicNode.Controller = "Products";
dynamicNode.Action = "Index";
dynamicNode.ParentKey = category.ProductType.Name.ToLower().Replace(" ", "-");
dynamicNode.RouteValues.Add("productType", category.ProductType.Name.ToLower().Replace(" ", "-"));
dynamicNode.RouteValues.Add("category", category.Name.ToLower().Replace(" ", "-"));
dynamicNode.ImageUrl = category.CategoryImage();
yield return dynamicNode;
}
}
}

Related

#Html.ActionLink returning a parameterised hyperlink

I am calling:
#Html.ActionLink("Register", "Register", "Account", new {area="FrontEnd"})
expecting
www.example.com/Account/Register
but I'm getting
http://www.example.com/?action=Register&controller=Account&area=FrontEnd
I'm a little confused as to why this happening, what can I do to resolve this?
Edit
When I remove the area parameter at the end, it builds the link correctly but to my admin section instead of FrontEnd.
Edit
I've isolated it to a custom route I have setup:
routes.Add("CategoriesRoute", new CategoriesRoute()); //This is breaking ActionLink.
routes.MapRoute("Default",
"{controller}/{action}/{id}",
new { controller = "Home", action = "Index", area = "FrontEnd", id = UrlParameter.Optional },
new[] { "CC.Web.Areas.FrontEnd.Controllers" }
);
This is the custom route.
public class CategoriesRoute : Route
{
public CategoriesRoute()
: base("{*categories}", new MvcRouteHandler())
{
}
public override RouteData GetRouteData(HttpContextBase httpContext)
{
var rd = base.GetRouteData(httpContext);
if (rd == null)
return null;
string categories = rd.Values["categories"] as string;
if (categories.IsEmpty())
return null;
string[] parts = categories.Split('/');
if (not a category) //pseudo code
return null;
rd.Values["controller"] = "Category";
rd.Values["action"] = "Index";
rd.Values["area"] = "FrontEnd";
rd.DataTokens.Add("namespaces", new string[] { "CC.Web.Areas.FrontEnd.Controllers" });
rd.Values["categoryString"] = "/" + categories; //Required to get exact match on the cache.
return rd;
}
}
Try the below. I think you're missing an attribute null at the end
#Html.ActionLink("Register", "Register", "Account", new {area="FrontEnd"}, null)
You need to add null for the 5th parameter (html attributes)
#Html.ActionLink("Register", "Register", "Account", new {area="FrontEnd"}, null)

MVC 4 - routes turn into a query string

I downloaded the Chinook sample database and I'm trying to build a simple application to teach myself MVC 4 and EF. Chinook is basically a bunch of artists, their albums and tracks in each album. I wanted to create a REST-like URL structure for displaying each artist, their albums and tracks. This is what I was envisioning:
Chinook/Artist <- List of artists
Chinook/Artist/{id} <- Artist details
Chinook/Artist/{id}/Album <- List of albums for artist
Chinook/Artist/{id}/Album/{id} <- Details for an album
Chinook/Artist/{id}/Album/{id}/Track <- Track listing for album
Chinook/Artist/{id}/Album/{id}/Track/{id} <- Track details
This is what my Route.config looks like:
routes.MapRoute(
name: "chinook_artist",
url: "Chinook/Artist/{id}/{action}",
defaults: new { controller = "Artist", action = "Index", id = UrlParameter.Optional }
);
routes.MapRoute(
name: "chinook_album",
url: "Chinook/Artist/{ArtistId}/Album/{id}/{action}",
defaults: new { controller = "Album", action = "Index", id = UrlParameter.Optional, ArtistId = UrlParameter.Optional }
);
routes.MapRoute(
name: "chinook_track",
url: "Chinook/Artist/{ArtistId}/Album/{AlbumId}/Track/{id}/{action}",
defaults: new { controller = "Track", action = "Index", id = UrlParameter.Optional, ArtistId = UrlParameter.Optional, AlbumId = UrlParameter.Optional }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
My idea was to have Edit, Delete and Add actions as well for all three types of objects, and Index would serve as listing.
This works well enough, however this is what my URLs look like:
http://localhost:57264/Album/?ArtistId=8&AlbumId=10&TrackId=70
In all cases I'm using Html.ActionLink with controller, action, etc. To generate links in views. But I can't get my URLs to start with "Chinook" and be "clean" rather than use a query string, like this:
http://localhost:57264/Chinook/Artist/8/Album/10/Track/70
So I'm not sure what I'm doing wrong here. Am I going against the grain of how MVC is supposed to work? Are my routes defined incorrectly?
Looking at the last 2 routes
Chinook/Artist/{id}/Album/{id}/Track
Chinook/Artist/{id}/Album/{id}/Track/{id}
Your route definitions would need to be
//Chinook/Artist/{artistid}/Album/{albumid}/Track
routes.MapRoute(
name: "chinook_track",
url: "Chinook/Artist/{ArtistId}/Album/{AlbumId}/Track",
defaults: new { controller = "Track", action = "Index" }
);
//Chinook/Artist/{artistid}/Album/{albumid}/Track/{id}
routes.MapRoute(
name: "chinook_trackdetails",
url: "Chinook/Artist/{ArtistId}/Album/{AlbumId}/Track/{id}",
defaults: new { controller = "Track", action = "Details", id = UrlParameter.Optional }
);
and your controller
public class TrackController : Controller
{
public ActionResult Index(int ArtistId, int AlbumId)
{
....
}
public ActionResult Details(int ArtistId, int AlbumId, int id)
{
....
}
}
and in the view, generate the links as
#Html.ActionLink("Index", "Index", "Track", new { ArtistId = 5, AlbumId = 3 }, null)
#Html.ActionLink("Details", "Details", "Track", new { ArtistId = 5, AlbumId = 3, id = 7 }, null)
Then if you wanted a route to edit a track in a similar format
//Chinook/Artist/{artistid}/Album/{albumid}/Track/{id}/Edit
routes.MapRoute(
name: "chinook_trackedit",
url: "Chinook/Artist/{ArtistId}/Album/{AlbumId}/Track/{id}/Edit",
defaults: new { controller = "Track", action = "Edit"}
);
public ActionResult Edit(int ArtistId, int AlbumId, int id)
{
....
}
#Html.ActionLink("Edit", "Edit", "Track", new { ArtistId = 5, AlbumId = 3, id = 7 }, null)

UrlHelper.Action not generating pretty url

I'm generating SEO-friendly "Pretty URLs" using this custom route, inspired by posts here on stackoverflow:
// Route used for Details view
routes.Add("CarDetailsRoute", new SeoFriendlyRoute(
url: "car/{state}/{community}/{make}/{model}/{slug}-{id}",
valuesToSeo: new string[] { "state", "community", "make", "model", "slug" },
defaults: new RouteValueDictionary(new { controller = "Vehicle", action = "Details", slug = UrlParameter.Optional, state = UrlParameter.Optional, community = UrlParameter.Optional, make = UrlParameter.Optional, model = UrlParameter.Optional }),
constraints: new RouteValueDictionary(new { id = #"\d+" })
));
/// The interesting route
routes.Add("CarIndexRoute", new SeoFriendlyRoute(
url: "car/{state}/{community}/{make}/{model}/",
valuesToSeo: new string[] { "state", "community", "make", "model" },
defaults: new RouteValueDictionary(new { controller = "Vehicle", action = "Index", state = UrlParameter.Optional, community = UrlParameter.Optional, make = UrlParameter.Optional, model = UrlParameter.Optional })
));
// Unrelated routes
// Fallback default route
routes.Add("Default", new SeoFriendlyRoute(
url: "{controller}/{action}/{id}",
valuesToSeo: new string[] { "action", "controller" },
defaults: new RouteValueDictionary(new { controller = "Home", action = "Index", id = UrlParameter.Optional }))
);
However, when I'm generating the breadcrumbs from my Details view to generate generic searches on parts of the details information, somehow the custom route fails and the default route kicks in.
// RequestContext here being a full qualified with Make, Model, Community
// and State. These are inserted in to all .Action()s by default, so i have
// to "remove them" where I don't want them.
var url = new UrlHelper(Request.RequestContext);
var breadcrumbs = new List<IBreadcrumbLink>() {
new BreadcrumbLink() {
Title = vehicle.Dealer.State,
Url = url.Action("Index", new { model = string.Empty, make = string.Empty, community = string.Empty })
// Doesn't work, generates ~/vehicle/?model=aaa&make=bbb&community=ccc&state=ddd
// Expected ~/car/state/
},
new BreadcrumbLink() {
Title = vehicle.Dealer.Community,
Url = url.Action("Index", new { model = string.Empty, make = string.Empty })
// Works, generates ~/car/state/community/
},
new BreadcrumbLink(){
Title = vehicle.Make,
Url = url.Action("Index", new { model = string.Empty })
// Works, generates ~/car/state/community/make/
},
new BreadcrumbLink(){
Title = vehicle.Model,
Url = url.Action("Index")
// Works, generates ~/car/state/community/make/model/
}
};
What would cause this behavior? Visiting the expected url of ~/car/state/ works like a charm, but as stated I cannot generate this url?

Route segment parameter issues

I am running into a problem with my routes in MVC4.
I have a few actions that live outside of a specific product and many more that live within the user chosen product. In order to accommodate the actions I have mapped two routes
context.MapRoute(
"CMS_product",
"CMS/{productId}/{controller}/{action}/{id}",
new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, productId = default(Guid).ToString(), id = UrlParameter.Optional },
new string[] { "Areas.CMS.Controllers" }
);
context.MapRoute(
"CMS_default",
"CMS/{controller}/{action}/{id}",
new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
new string[] { "Areas.CMS.Controllers" }
);
So while this works in a generic since, none of my routes will match the default route any longer and instead of getting a URL like
~/CMS/Product/List
When operating outside of a product I get urls like this.
~/CMS/00000000-0000-0000-0000-000000000000/Product/List
Another note: I have tried to hard code the Prodcut/List in as a route, and have placed it before CMS_product in the hopes that it would match prior to the other url. I feel like I must be overlooking something simple.
For completeness, should anyone else run into a similar issue here is the solution.
// used to match ~/CMS/00000000-0000-0000-0000-000000000000/Product/List
// prevents the GUID.Empty from showing when there is no product value
// in the segment
context.MapRoute(
name: "CMS_nullproduct",
url: "CMS/{controller}/{action}/{id}",
defaults: new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
constraints: new { productId = Guid.Empty.ToString() },
namespaces: new string[] { "Areas.CMS.Controllers" }
);
// matches any route with a productId segment value of anything aside from
// GUID.Empty
context.MapRoute(
name: "CMS_product",
url: "CMS/{productId}/{controller}/{action}/{id}",
defaults: new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
constraints: new { productId = #"^(\{{0,1}([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}\}{0,1})$" },
namespaces: new string[] { "Areas.CMS.Controllers" }
);
context.MapRoute(
name: "CMS_default",
url: "CMS/{controller}/{action}/{id}",
defaults: new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
namespaces: new string[] { "Areas.CMS.Controllers" }
);
In my opinion you should remove default value for productId.
context.MapRoute(
"CMS_product",
"CMS/{productId}/{controller}/{action}/{id}",
new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
new string[] { "Areas.CMS.Controllers" }
);
If you not provide productId you routing engine should match second route and generate ~/CMS/Product/List but if you provide productId it match first rule.
Additionaly you can write custom IRouteConstraint or use regex to limit productId values.
context.MapRoute(
"CMS_product",
"CMS/{productId}/{controller}/{action}/{id}",
new { controller = MVC.CMS.Home.Name, action = MVC.CMS.Home.ActionNames.Index, id = UrlParameter.Optional },
new { productId = #"^\d{8}\-\d{4}\-\d{4}\-\d{4}\-\d{12}$" }
new string[] { "Areas.CMS.Controllers" }
);

ASP.NET MVC 3 - Area URL Routing Problem

I have an ASP.Net MVC 3 application. With 2 Areas:
Auth - handles all the authentication etc
Management - area for property management
In the Management Area I have a ManagementController and a PropertyController. The ManagementController does nothing, it only has a simple Index ActionResult than return a view.
The PropertyController has an Index ActionResult that returns a view with a list of properties as well as an Edit ActionResult that takes a propertyId as parameter. In the Property Index view i have a grid with the list of properties and an option to edit the property with the following code:
#Html.ActionLink("Edit", "Edit","Property", new { id = item.PropertyId })
In theory this should redirect to the Edit ActionResult of my Property Controller,however it redirects to the Index ActionResult of my ManagementController. My ManagementAreaRegistration file looks like this:
context.MapRoute(null, "management", new { controller = "management", action = "Index" });
context.MapRoute(null, "management/properties", new { controller = "Property", action = "Index" });
context.MapRoute(null, "management/properties/Edit/{propertyId}", new { controller = "Property", action = "Edit" });
And my global.asax's RegisterRoutes like this:
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"Default", // Route name
"{controller}/{action}/{id}", // URL with parameters
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
);
What am I missing, why would it redirect to the wrong controller and action?
Thanks in Advance!
You might need to specify the area in your route values parameter:
#Html.ActionLink("Edit", "Edit", "Property", new { id = item.PropertyID, area = "Management" }, new { })
Based on the constructor used for this call, you need to specify the last parameter, htmlAttributes, which, for your purposes, would be empty.
In your route you defined a parameter called propertyId not id:
context.MapRoute(null, "management/properties/Edit/{propertyId}", new { controller = "Property", action = "Edit" });
Try this:
#Html.ActionLink("Edit", "Edit","Property", new { propertyId = item.PropertyId })
I'd suggest using constraints.
For example my default route in the Global.asax is as follows:
routes.MapRoute(
"Default",
"{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new string[] { typeof(MyProject.Controllers.HomeController).Namespace });
Then in my area registration:
context.MapRoute(
"Search_default",
"Search/{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new string[] { typeof(MyProject.Areas.Search.Controllers.HomeController).Namespace }
);
This means I get {AppName}/ as well as {AppName}/Search/Home and both work a treat.
From any Area to Home do following
Url.Action("Details", "User", new { #id = entityId, area = "" })
From Home to any Area
Url.Action("Details", "Order", new { #id = entityId, area = "Admin" })

Resources