Views: 964
Number of votes: 1
Average rating:

Dynamic UI menu in MVC

I am writing this blog post to explain how to create a MVC UI menu in Episerver that works with dynamic routes. In this blog post, I will go through one way to achieve this by using a MenuProvider, however there is another way to achieve this by using the MenuItemAttribute. I will not go down but not go into too much detail on that as it requires more work. Also worth noting is that this solution has a minor UI issue due to a limitation that currently exist in Episerver UI 11.27.

If you are interested in brushing up on your menu item creation skills or want an example on how to create a UI menu, then take a look at my other blog post "Creating your own menu inside the Episerver UI using MVC".

Menu items

Before I go into detail on how to create a dynamic menu I thought I should quickly go over how a normal menu look and work. This is the corner stone to make it work for dynamic menues.

As mentioned in my other post, adding an item is very easy. All you need to do is to create a class with the attribute MenuProvider and then inherit the IMenuProvider interface.

Each menu item can then be created by adding a new MenuItem to the output list of menu items. I.e.

items.Add(new UrlMenuItem("{name}", MenuPaths.Global + "/{path}", "{url}")
{
    SortIndex = 1000,
    IsAvailable = (_) => true
});

(A UrlMenuItem is an extended menu item which exposes a new constructor for accepting the url parameter, nothing else.)

The menu is then built based on all these menu items in all MenuProviders. Each MenuProvider get's pulled in from the ServiceLocator where they are registered during startup and parsed in the MenuAssembler class. As I mentioned in my other blog post, if you do not have a parent then the ServiceNavigator class will be unable to construct the menu, which will result in a blank menu. (Order of methods being called: cshtml file whitch has @Html.Raw(Html.CreatePlatformNavigationMenu()) > MenuHelper class > NavigationService class > MenuAssembler class.)

There is no caching taking place in the MenuAssembler class but instead, for each specific call from the ServiceNavigtor class (Product name, menu level one and menu level two), a call is made to the MenuAssembler class which loads all menu providers and returns a list of matching menu items.

Dynamic menu

Once we have decided what menu items we want then we need to register them in our MenuProvider class so that the correct one can be picked when the page is loaded, and it's here where the challenge lies. With the current implementation of Menuitem this is not possible as the check is based on if we match the entire url (which we don't if we have a dynamic route).

No dynamic route (works)

Example (no dynamic argument) which yields the url: {site}/education/sessions/view

Controller class:

[RoutePrefix("education/sessions")]
public class EducationSessionController : Controller
{
    [Route("view"), HttpGet]
    public ActionResult View()
    {
        return View();
    }
}

MenuProvider class:

items.Add(new UrlMenuItem("View session", MenuPaths.Global + "/education/sessions/view", "/education/sessions/view")
{
    SortIndex = 1000,
    IsAvailable = (_) => true
});

Dynamic route (does not work)

Example (dynamic argument id) which yields the url: {site}/education/sessions/view/{id}

Controller class:

[RoutePrefix("education/sessions")]
public class EducationSessionController : Controller
{
    [Route("view/{sessionId:int}"), HttpGet]
    public ActionResult View(int sessionId)
    {
        return View();
    }
}

MenuProvider class:

items.Add(new UrlMenuItem("View session", MenuPaths.Global + "/education/sessions/view", "/education/sessions/view")
{
    SortIndex = 1000,
    IsAvailable = (_) => true
});

As you most likely already have figured out. There is no way of informing the MenuItem that this item (incoming route) is dynamic. What we want is a way to express the following route /education/sessions/view/* where the star represents a dynamic route. 

Solution

In order to achieve this, we need to make the MenuItem aware about dynamic routes and act accordingly. Luckily this is very simple to achieve (barring for the one UI display issue). 

We start with creating a new class called UrlMenuItemExtended which inherit from the UrlMenuItem class. In this class we create a property called IsDynamic that informs us if we have a dynamic route or not. After this, we override the IsSelected method with our own custom implementation that can detect dynamic urls.

The code looks like this:

using System.Web.Routing;
using EPiServer.Shell;
using EPiServer.Shell.Navigation;

namespace Episerver.Sessions
{
    public class UrlMenuItemExtended : UrlMenuItem
    {
        public bool IsDynamic { get; }

        public UrlMenuItemExtended(string text, string path, string url, bool isDynamic = false) : base(text, path, url)
        {
            IsDynamic = isDynamic;
        }

        public override bool IsSelected(RequestContext requestContext)
        {
            Validate.RequiredParameter(nameof(requestContext), (object)requestContext);
            if (string.IsNullOrEmpty(Url))
                return false;

            var menuItemUrl = this.Url.Trim('/').ToLowerInvariant();
            var browserUrl = requestContext.HttpContext.Request.Path.Trim('/').ToLowerInvariant();

            // If we have a dynamic menu item, check with StartsWith
            if (IsDynamic && browserUrl.StartsWith(menuItemUrl))
                return true;

            // If we have a static menu item, check with Equals
            if (!IsDynamic && browserUrl.Equals(menuItemUrl))
                return true;

            return false;
        }
    }
}

To use this we simply change from UrlMenuItem to UrlMenuItemExtended in the MenuProvider class.

items.Add(new UrlMenuItemExtended("View session", MenuPaths.Global + "/education/sessions/view", "/education/sessions/view", true)
{
    SortIndex = 1000,
    IsAvailable = (_) => true
});

This now matches everything up until /education/sessions/view but ignores the dynamic route values if the dynamic flag is set to true, otherwise it falls back to the original implementation.

Important: The order of which you add the items matter. This is because the MenuAssembler class sorts all items by depth.

If you have 2 items with the same depth (see a reference to my other blog at the top if you are unsure about depth) then the order of which they were added matter. Easiest to avoid this is by having unique urls. 

Avoid this:
/education/sessions/1
/education/sessions/list

In favor of:
/education/sessions/view/1
/education/sessions/list 

More advanced routing

If you need to do more advanced routing then you will need to use the RouteValues provided in the incoming parameter requestContext to do your matching. In this case I would suggest that you use the existing class RouteMenuItem which - instead of the Dynamic flag - accepts a RouteValueDictionary where you can populate the required data. These are then available to you in the IsSelected method.

The UI issue

As I mentioned earlier. There is one UI related issue and that is the link that is generated in the menu (<a href>). This means that you will get a menu item with a url that points to a method that doesn't have the dynamic route (I.e. the url.) So you will need to create a method that redirects any incoming calls to a list or similar method. I.e. the link in the menu will point to {site}/education/sessions/view and not to {site}/education/sessions/view/id or a url specified by you. The most optimal solution would have been to have a display url that you could populate to change where the people go when they click on the url.

So how do we get around this? (assuming that we don't want to create a method to handle this.)

Aha moment

It sounded like a trivial task to solve and that's what I thought as well. I checked through the code and saw that I could override a method (inside my UrlMenuItemExtended class) called RenderContents. This would then be called instead of the base implementation to render the menu item.

I copied the code from the MenuItem RenderContents method and pasted it into my overriden version in the UrlMenuItemExtended class, put a breakpoint and hit debug. Visual Studio first stopped at the IsSelected method so that I could verify that my implementation of dynamic menues worked which it did. I hit F5 to continue to the render method... or so I thought. Visual Studio finished rendering the site and left me with a menu that pointed towards the {site}/education/sessions/view url.

At first I thought I had missed something and tried it again but got the same result. I started going through the code to find why it behaved like this, and that is when I stumbled upon the fact that the RenderContents method is not used anymore (one annoying fact with Episerver's decision of forever backwards compability, a lot of obsolete code.) Since it is no longer in use (for this purpose), it of course does not get called in newer versions of the CMS UI. Instead, the javascript at the frontend calls an API endpoint which in turn call the NavigationService -> MenuAssembler classes to return the links in json form. This means that the html/css are now rendered in the frontend instead of in the old Render method. 

There is no way to override the creation of the menu items as those reside in a class that can't be overridden. Until this is resolved, there is no way around this even if you create your own NavigationService class (where the mapping takes place). At least not until this has been resolved in the CMS UI package.

MenuItem attribute

I mentioned at the top that there are two ways to implement dynamic routes. This is true, however this only applies if you have created your own shell module. Meaning, if you want to create everything in one project then this is not possible as the shell module responsible for this to work, points to the shell modules folder. Unless your controllers use that as their routed url, it won't work.

You can read more about the MenuItem attribute here.

In short, what this does is allowing you to register the MenuItem's using an attribute instead. Like this:

[RoutePrefix("education/sessions")]
public class EducationSessionController : Controller
{
    [Route("view/{sessionId:int}"), HttpGet]
    [MenuItem(MenuPaths.Global + "/education/sessions/view", Text = "View sessions")]
    public ActionResult View(int sessionId)
    {
        return View();
    }
}

This is automatically infered resolved using a MenuProvider under the hood called ReflectingMenuItemProvider. This is loaded along with all other modules, and checks all controller methods for the MenuItem attribute using reflection. If it finds a MenuItem attribute it creates a RouteMenuItem that passes along a RouteValueDictionary object which is then used to validate the IsSelected method.

I hope that this blog post has clarified some of the mysteries with creating UI menu items for dynamic MVC routes.

Jul 31, 2020

Please login to comment.