Dynamic MudBlazor Menus

Dynamic MudBlazor Menus

MudBlazor is an open source UI library full of great Razor controls. Since the library is open source, it’s fun and easy to extend. For instance, one of the things I’ve created is a component that dynamically creates a navigation menu, at runtime. It’s a handy control to have for bigger sites since it obviates the need to manually keep the nav menu in sync with various pages. I thought I would present that control today.

Some Background

MudBlazor already has a perfectly decent navigation menu component, called MudNavMenu. That component looks something like this:

The markup to produce that menu looks like this:

<MudPaper Width="250px" Class="py-3" Elevation="0">
    <MudNavMenu>
        <MudText Typo="Typo.h6" Class="px-4">My Application</MudText>
        <MudText Typo="Typo.body2" Class="px-4 mud-text-secondary">Secondary Text</MudText>
        <MudDivider Class="my-2"/>
        <MudNavLink Href="/dashboard">Dashboard</MudNavLink>
        <MudNavLink Href="/servers">Servers</MudNavLink>
        <MudNavLink Href="/billing" Disabled="true">Billing</MudNavLink>
        <MudNavGroup Title="Settings" Expanded="true">
            <MudNavLink Href="/users">Users</MudNavLink>
            <MudNavLink Href="/security">Security</MudNavLink>
        </MudNavGroup>
        <MudNavLink Href="/about">About</MudNavLink>
    </MudNavMenu>
</MudPaper>

Pretty easy stuff. The only drawback to this approach though, is that the developer has to edit this markup any time a new page is added, or an existing page is moved, or removed. In other words, over time, for bigger sites, the nav menu markup becomes a maintenance problem.

A New Menu

My new navigation menu consists of a control and an associated attribute. They work together to dynamically build a menu, at runtime. The look and feel is really no different than the standard MudNavMenu I showed above. The markup is quite different though, and doesn’t have to change as we add pages, or move things around.

Let’s look at the code …

The Attribute

The attribute that drives the menu is called DynamicNavMenuAttribute, and the code looks likes this:

/// <summary>
/// This class is an attribute that denotes an associated dynamic navigation 
/// menu.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class DynamicNavMenuAttribute : Attribute
{
    /// <summary>
    /// This property contains the title for the menu.
    /// </summary>
    public string MenuTitle { get; } = null!;

    /// <summary>
    /// This property contains the icon for the menu.
    /// </summary>
    public string? MenuIcon { get; set; }

    /// <summary>
    /// This property contains the color for the menu.
    /// </summary>
    public string? MenuColor { get; set; }

    /// <summary>
    /// This property contains the title for the group menu.
    /// </summary>
    public string? GroupTitle { get; set; }

    /// <summary>
    /// This property contains the icon for the group menu.
    /// </summary>
    public string? GroupIcon { get; set; }

    /// <summary>
    /// This property contains the color for the group menu.
    /// </summary>
    public string? GroupColor { get; set; }

    /// <summary>
    /// This property contains the route for the menu.
    /// </summary>
    public string? MenuRoute { get; set; }

    /// <summary>
    /// This property contains the match for the menu.
    /// </summary>
    public NavLinkMatch? MenuMatch { get; set; }

    /// <summary>
    /// This property indicates whether or not the menu is disabled.
    /// </summary>
    public bool MenuDisabled { get; set; }

    /// <summary>
    /// This constructor creates a new instance of the <see cref="DynamicNavMenuAttribute"/>
    /// </summary>
    /// <param name="menuTitle">The title for the menu.</param>
    public DynamicNavMenuAttribute(
        string menuTitle
        )
    {
        // Save the reference(s).
        MenuTitle = menuTitle;
    }
}

The attribute contains the properties I usually like to manipulate, when building a menu. The way the attribute is used is by adding it to the markup for a razor page, like this:

@page "/test"

@using CG.Blazor.Components
@attribute [DynamicNavMenu("Test")]

The only required property, for the attribute, is the title of the menu. I used “Test” for this demonstration. Other parameters are optional. For instance, I could create a group for my menu by adding a GroupTitle property, like this:

@page "/test"

@using CG.Blazor.Components
@attribute [DynamicNavMenu("Test", GroupTitle = "Test Group")]

Whatever properties you decide to add, when specifying your attribute, the result is that your razor page will get picked up and added to the dynamic menu, as a menu item.

The Menu

Adding the control to the layout of your site is easy. Go to your layout page and replace the NavMenu component, with a DynamicNavMenu component, like this:

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        @*<NavMenu />*@
        <DynamicNavMenu />   <---  Add this line
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

Note that you could also keep the original NavMenu component, and add the DynamicaNavMenu above or below it, so some menu items would be dynamic awhile others would be more traditional. It’s really up to you. Also, you can add static content to my dynamic menu with no problem.

The markup for the DynamicNavMenu component looks like this:

@namespace CG.Blazor.Components
@inherits MudNavMenu

@if (ChildContentOnTop)
{
    <div @attributes="UserAttributes" class="@Classname" style="@Style">
        @ChildContent
    </div>
}

@foreach (var menuItem in _menuItems.Where(x => x.GroupTitle?.Length == 0))
{
    <MudNavLink IconColor=@(menuItem.MenuColor ?? Color.Inherit) 
                Match=@(menuItem.MenuMatch ?? NavLinkMatch.Prefix)
                Icon=@menuItem.MenuIcon 
                Disabled=@menuItem.MenuDisabled
                Href=@menuItem.MenuRoute>@menuItem.MenuTitle</MudNavLink>
}

@foreach (var menuItem in _menuItems.Where(x => x.GroupTitle?.Length > 0))
{
    <MudNavGroup Title=@menuItem.GroupTitle 
                 Icon=@menuItem.GroupIcon 
                 IconColor=@(menuItem.GroupColor ?? Color.Inherit)>
        @foreach (var groupItem in _menuItems.Where(x => x.GroupTitle == menuItem.GroupTitle))
        {
            <MudNavLink IconColor=@(groupItem.MenuColor ?? Color.Inherit)
                        Match=@(groupItem.MenuMatch ?? NavLinkMatch.Prefix) 
                        Icon=@groupItem.MenuIcon
                        Disabled=@menuItem.MenuDisabled
                        Href=@groupItem.MenuRoute>@groupItem.MenuTitle</MudNavLink>
        }
    </MudNavGroup>
}

@if (!ChildContentOnTop)
{
    <div @attributes="UserAttributes" class="@Classname" style="@Style">
        @ChildContent
    </div>
}

First we check a ChildContentOnTop property. That property allows us to position any static content above, or below the dynamic content. So, if the ChildContentOnTop property is true, we render it on the top, otherwise, we render it on the bottom.

Next, we loop through the contents of the _menuItems field. We’ll see how that field gets built when we look at the code-behind, shortly. For now, just be aware that the _menuItems collection contains properties for our menu items.

For each menu item, in _menuItems, that doesn’t contain a group title, we simply render that item as a MudNavLink item. In other words, as a menu without a containing group.

For each menu item, in _menuItems, that does have a group title, we first render the outer group as a MudNavGroup component, then we iterate through any matching menu items, rendering each of those as a MudNavLink component, inside the outer MudNavGroup component. The result of all this rendering is that we’ll have dynamically built a menu, at runtime.

The code-behind, for the DynamicNavMenu component, looks like this:

/// <summary>
/// This class is the code-behind for the <see cref="DynamicNavMenu"/> component.
/// </summary>
public partial class DynamicNavMenu : MudNavMenu
{
    /// <summary>
    /// This class contains properties for a menu item.
    /// </summary>
    public class _MenuItem
    {
        /// <summary>
        /// This property contains the title for the group menu 
        /// </summary>
        public string? GroupTitle { get; set; }

        /// <summary>
        /// This property contains the icon for the group menu 
        /// </summary>
        public string? GroupIcon { get; set; }

        /// <summary>
        /// This property contains the color for the group menu 
        /// </summary>
        public Color? GroupColor { get; set; }

        /// <summary>
        /// This property contains the title for the menu 
        /// </summary>
        public string? MenuTitle { get; set; }

        /// <summary>
        /// This property contains the color for the menu 
        /// </summary>
        public Color? MenuColor { get; set; }

        /// <summary>
        /// This property contains the icon for the menu 
        /// </summary>
        public string? MenuIcon { get; set; }

        /// <summary>
        /// This property contains the route for the menu 
        /// </summary>
        public string? MenuRoute { get; set; }

        /// <summary>
        /// This property indicates whether or not the menu is disabled.
        /// </summary>
        public bool MenuDisabled { get; set; }

        /// <summary>
        /// This property contains the match for the menu 
        /// </summary>
        public NavLinkMatch? MenuMatch { get; set; }
    }

    /// <summary>
    /// This field contains the list of menu items.
    /// </summary>
    internal protected readonly List<_MenuItem> _menuItems = new();

    /// <summary>
    /// This property indicates whether or not to render any child content
    /// before the dynamic menu elements. If <c>false</c>, child content
    /// is rendered after dynamic menu elements.
    /// </summary>
    public bool ChildContentOnTop { get; set; }

    /// <summary>
    /// This method is called to initialize the component.
    /// </summary>
    protected override void OnInitialized()
    {
        // Log what we are about to do.
        Logger.LogDebug(
            "Searching for assemblies with razor pages decorated with a {name} attribute",
            nameof(DynamicNavMenuAttribute)
            );

        // Look for any assemblies with decorated razor pages.
        var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(x =>
            x.GetTypes().Any(y => y.CustomAttributes.Any(y => y.AttributeType == 
            typeof(DynamicNavMenuAttribute)))
            );

        // Did we find any?
        if (assemblies.Any())
        {
            // Log what we are about to do.
            Logger.LogDebug(
                "Looping through {count} assemblies with razor pages " +
                 "decorated with a {name} attribute",
                nameof(DynamicNavMenuAttribute)
                );

            // Loop through the assemblies.
            foreach (var assembly in assemblies)
            {
                // Log what we are about to do.
                Logger.LogDebug(
                    "Searching for razor pages decorated with a {name} attribute",
                    nameof(DynamicNavMenuAttribute)
                    );

                // Look for any classes decorated with the plugin page attribute.
                var decoratedTypes = assembly.ExportedTypes.Where(x =>
                    x.CustomAttributes.Any(y => y.AttributeType == 
                    typeof(DynamicNavMenuAttribute))
                    );

                // Did we find any?
                if (decoratedTypes.Any())
                {
                    // Log what we are about to do.
                    Logger.LogDebug(
                        "Looping through {count} types of razor pages " +
                          "decorated with a {name} attribute",
                        nameof(DynamicNavMenuAttribute)
                        );

                    // Loop though the types.
                    foreach (var type in decoratedTypes)
                    {
                        // Log what we are about to do.
                        Logger.LogDebug(
                            "Searching for a {name} attribute for page: {page}",
                            nameof(DynamicNavMenuAttribute),
                            type.Name
                            );

                        // Look for a plugin page attribute.
                        var pluginPageAttribute = type.CustomAttributes.FirstOrDefault(y 
                         => y.AttributeType == typeof(DynamicNavMenuAttribute)
                            );

                        // Can we take a shortcut?
                        if (pluginPageAttribute is null)
                        {
                            // Log what happened.
                            Logger.LogWarning(
                                "Unable to find a {attr} attribute for " +
                                "razor page: {type}",
                                nameof(DynamicNavMenuAttribute),
                                type.Name
                                );

                            continue; // Nothing to do!
                        }

                        // Log what we are about to do.
                        Logger.LogDebug(
                            "Recovering menu properties from a {name} attribute",
                            nameof(DynamicNavMenuAttribute)
                            );

                        // Look for the menu group title.
                        var menuGroupTitle = 
                            pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.GroupTitle)
                            );

                        // Look for the menu group icon.
                        var menuGroupIcon = 
                            pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.GroupIcon)
                            );

                        // Look for the menu group color.
                        var menuGroupColor = 
                           pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.GroupColor)
                            );

                        // Look for the menu title.
                        var menuTitle = 
                            $"{pluginPageAttribute.ConstructorArguments.FirstOrDefault()}"
                            .Trim('"');

                        // Look for the menu icon.
                        var menuIcon = pluginPageAttribute.NamedArguments.FirstOrDefault(
                            x => x.MemberName == nameof(DynamicNavMenuAttribute.MenuIcon)
                            );

                        // Look for the menu color.
                        var menuColor = 
                           pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.MenuColor)
                            );

                        // Look for the menu match.
                        var menuMatch = 
                            pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.MenuMatch)
                            );

                        // Look for the menu disabled flag.
                        var menuDisabled = 
                            pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                            x.MemberName == nameof(DynamicNavMenuAttribute.MenuDisabled)
                            );

                        // Look for the menu route.
                        var menuRoute = 
                        pluginPageAttribute.NamedArguments.FirstOrDefault(x =>
                        x.MemberName == nameof(DynamicNavMenuAttribute.MenuRoute)
                            );

                        // Are we missing a route?
                        var route = "";
                        if (menuRoute.TypedValue.Value is null)
                        {
                            // Log what we are about to do.
                            Logger.LogDebug(
                                "No route specified in the {name} " +
                                 "attribute - looking in the page: {page}",
                                nameof(DynamicNavMenuAttribute),
                                type.Name
                                );

                            // Look for a route attribute.
                            var routeAttribute = type.CustomAttributes.FirstOrDefault(y =>
                                y.AttributeType == typeof(RouteAttribute)
                                );

                            // Are we missing a route attribute?
                            if (routeAttribute is null)
                            {
                                // Log what happened.
                                Logger.LogWarning(
                                    "Unable to find a route for menu " +
                                    "group: {group}, item: {item}",
                                    $"{menuGroupTitle.TypedValue.Value}",
                                    $"{menuTitle}"
                                    );

                                continue; // Nothing to do!
                            }

                            // Log what we are about to do.
                            Logger.LogDebug(
                                "Assigning route: {route} to menu group: " +
                                "{group}, title: {title}",
                                $"{routeAttribute.ConstructorArguments.First().Value}",
                                $"{menuGroupTitle.TypedValue.Value}",
                                $"{menuTitle}"
                                );

                            // Assign the route from the attribute.
                            route = 
                            $"{routeAttribute.ConstructorArguments.First().Value}";
                        }
                        else
                        {
                            // Log what we are about to do.
                            Logger.LogDebug(
                                "Assigning route: {route} to menu group: " +
                                 " {group}, title: {title}",
                                $"{menuRoute.TypedValue.Value}",
                                $"{menuGroupTitle.TypedValue.Value}",
                                $"{menuTitle}"
                                );

                            // Assign the route from the attribute.
                            route = $"{menuRoute.TypedValue.Value}";
                        }

                        // Log what we are about to do.
                        Logger.LogDebug(
                            "Parsing enum values"
                            );

                        // Try to parse the group color.
                        if (!Enum.TryParse<Color>($"{menuGroupColor.TypedValue.Value}", 
                            true, out var gColor))
                        {
                            gColor = Color.Inherit;
                        }

                        // Try to parse the menu color.
                        if (!Enum.TryParse<Color>($"{menuColor.TypedValue.Value}", 
                            true, out var mColor))
                        {
                            mColor = Color.Inherit;
                        }

                        // Try to parse the match.
                        if (!Enum.TryParse<NavLinkMatch>($"{menuMatch}", 
                            true, out var nlMatch))
                        {
                            nlMatch = NavLinkMatch.Prefix;
                        }

                        // Try to parse the disabled flag.
                        if (!bool.TryParse($"{menuDisabled}", out var disabled))
                        {
                            disabled = false;
                        }

                        // Log what we are about to do.
                        Logger.LogDebug(
                            "Creating menu item group: {group}, title: " +
                            " {title}, route: {route}",
                            $"{menuGroupTitle.TypedValue.Value}",
                            $"{menuTitle}",
                            route
                            );

                        // Create the menu item.
                        var menuItem = new _MenuItem()
                        {
                            GroupTitle = $"{menuGroupTitle.TypedValue.Value}",
                            GroupIcon = $"{menuGroupIcon.TypedValue.Value}",
                            GroupColor = gColor,
                            MenuTitle = $"{menuTitle}",
                            MenuIcon = $"{menuIcon.TypedValue.Value}",
                            MenuColor = mColor,
                            MenuMatch = nlMatch,
                            MenuDisabled = disabled,
                            MenuRoute = route,
                        };

                        // Add the menu item.
                        _menuItems.Add( menuItem );
                    }
                }
                else
                {
                    // Log what happened.
                    Logger.LogDebug(
                        "No razor pages decorated with a {name} attribute were found",
                        nameof(DynamicNavMenuAttribute)
                        );
                }
            }
        }
        else
        {
            // Log what happened.
            Logger.LogDebug(
                "No assemblies with razor pages decorated with a {name} " +
                "attribute were found",
                nameof(DynamicNavMenuAttribute)
                );
        }

        // Give the base class a chance.
        base.OnInitialized();
    }
}

The menu items are represented, internally, as _MenuItem objects. Each _MenuItem object contains the same properties we saw on the DynamicNavMenuAttribute class.

The component also contains a list of _MenuItem objects, called _menuItems. That’s the same collection we were using in the markup, to render to menu items. We also see the ChildContentOnTop property, that we also looked at earlier, in the markeup.

The menu items are constructed during the OnInitiailized method. We begin by looking for all assemblies, in the current AppDomain, that have one or more types that have been decorated with our DynamicNavMenuAttribute attribute. We then iterate through that collection of assemblies.

For each assembly, we then look for any types with an associated DynamicNavMenuAttribute attribute. We then iterate through that list of types.

For each decorated type, we then get a reference to the associated DynamicNavMenuAttribute attribute instance. That’s where we’ll find the properties we need to build a menu item with. After we copy all the properties from the attribute, we then make sure we have a route, since without a route our menu item won’t do anything. So, if a route is specified in the DynamicNavMenuAttribute attribute, we’ll use that in the resulting menu item. On the other hand, if a route isn’t specified we’ll use the route specified in the associated razor page. If, after all that, we still can’t find a route, we’ll log a warning and move on, since, as I said before, a menu item without a route won’t do anything.

The end result of all this reflection is a collection of populated _MenuItem objects in the _menuItems collection field.

Caveats

Everything in engineering is a trade-off, and this menu component is no exception. For instance, using this approach, we’ll trade maintaining a single navigation menu component for potentially dozens of individual attributes on razor pages. Also, doing fancy stuff with menus, like dynamically disabling menu items, is trickier with this component. Still I think the overall approach is a good one for some websites.

There is one area where I’ve discovered this menu doesn’t work well. That is if you’re using plugins and dynamically injecting razor pages, with their associated menu items, into the site. That’s because this menu component looks at all assemblies that have been loaded into the AppDomain, and some of those assemblies might be plugin libraries that have been referenced by the main application, but not loaded as a plugin. It’s an edge case, to be sure, but I want to point it out. In that case, this control would find any decorated razor pages in those assemblies and build menu items for them, even though some of those pages won’t be loaded, since they live in plugins that themselves, haven’t been loaded.

The answer to that issue is to build a dynamic menu control that knows about plugins and is smart enough to deal with that issue. I have a plan to build that component, but, I haven’t yet figured out how I want to package it. You see, my current plugin library (CG.Blazor.Plugins) doesn’t have a UI, so, it has no references to any 3rd party UI libraries (including MudBlazor). I want to keep that library free from UI entanglements, so I’ll probably have to stand up a new NUGET package to hold my menu control. For now though, I haven’t done that. If you use my plugin library and want a dynamic navigation menu that understands plugins, let me know, and I’ll focus on that a bit more.

Finish

So that’s it. A quick MudBlazor specific dynamic navigation menu that’s perfect for bigger sites, with lots of pages, or sites that have an architecture that’s spread out over multiple assemblies.

Usually, this is where I would point out which NUGET package contains my source, and possibly the source for a sample website. In this instance, I’ve not had the time to create any of that. To be honest, I’m still working through some details, with the code.

I’ll let everyone know when I’ve decided which NUGET package should contain this component.

Until then, thanks for reading! Have fun! :o)

Photo by Foo Visuals on Unsplash