Introduction
The idea began when we wanted to generate menu items not by hard-coding or reading from the database since in MVC application each view (or a page if you like to call) is rendered by action methods which are under a controller. And we chose to use attributes and reflection for that.
Benefits
- No hard-coded menu name in code
- You can add/remove menu just by applying attribute on a controller and/or action methods.
- Restricted menu, i.e., menu generation is controlled by access rights. Here, we used one attribute but you can add more if needed (but you need to change the code accordingly)
- Controlling the order of menu
- Promote an action method as a top level menu (in that case, do not apply attribute on the controller level)
- Make the menu non navigatable. For example, the controller name serves as a top level menu but you do not want to take to a view when it is clicked but only the sub level menus (actions) are clickable
- Apply icons (using fontawsome)
- Extensible - you can extend the features if you need more
Important: Source Download
The code opens in Visual studio 2015 without any issues. I am not sure if it opens in older versions and you need to take care of things yourself (may be you can just copy the important files).
The code is without Nuget packages (including them gets 20MB size) so you need to install manually otherwise the code will not build.
Here is how you install nuget packages:
Using the Code
MenuItemAttribute
is what plays the key role.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class MenuItemAttribute : Attribute
{
public MenuItemAttribute()
{
IsClickable = true;
}
public bool IsClickable { get; set; }
public string Title { get; set; }
public string Action { get; set; }
public string CssIcon { get; set; }
public int Order { get; set; }
public Type ParentController { get; set; }
}
All properties are optional, but you can use them based on your requirement. Here is what happens if you do not use it.
IsClickable
- by default true
. That means a view is rendered when you click it. If set to false
, then nothing happens, but you can access the submenus (if present).
Title
- By default, takes the controller name without the text "Controller
". Say "Home
" for a "HomeController
" class. Or Action
name if set on action
method
Action
- By default "Index
" and this is effective only when applied on the controller
. On action
method, it has no effect and it always takes the action name.
CssIcon
- By default, no icon. You can use the font-awesome icons.
Order
- By default 0
that means the menu is generated the order code reads the controller. If set, then it renders in ascending order.
ParentController
- If you want to make another controller as a sub menu of a controller.
One Example Controller
[MenuItem (Action = "Users")]
public class AdminController : Controller
{
public ActionResult Index()
{
return View();
}
[MenuItem(CssIcon = "fa fa-users fa-lg fa-fw")]
[AuthorizedRole("Admin")]
public ActionResult Users()
{
return View();
}
[MenuItem(Title = "Site Settings")]
[AuthorizedRole("Super user")]
public ActionResult Settings()
{
return View();
}
}
Ok, what happens here?
- First on a controller level, the Action is set to "Users" which means the users' view is rendered when clicking the "Admin" menu
- On the "Users" action, a font-awesome icon is set and it is restricted to only the users who have "Admin" role.
AuthorizedRole
is another custom attribute which takes care of how you check in real time. You can check the code and change the logic in whatever way you like in real time.
- On the "Settings" action, the title is changed to "Site Settings" and restricted to only users with "Super user" role.
This is just one sample. Just check the other controllers in the code by yourself.
How the attribute is converted as menu
The MenuItemAttribute
which is applied on Controllers and Actions is picked by the code dynamically and returns the list of Menu class which is then rendered as a nice Bootstrap menu by the code.
The below code is part of the MenuGenerator
class
public static List<Menu> CreateMenu()
{
var menus = new List<Menu>();
var currentAssembly = Assembly.GetAssembly(typeof(MenuGenerator));
var allControllers = currentAssembly.GetTypes().Where(t => t.IsSubclassOf(typeof(Controller))).ToList();
var menuControllers = allControllers.Where(t => t.GetCustomAttribute<MenuItemAttribute>() != null ||
t.GetMethods().Any(m => m.GetCustomAttribute<MenuItemAttribute>() != null))
.ToList();
var submenuControllers = new List<Menu>();
menuControllers.ForEach(controller =>
{
var navigation = controller.GetCustomAttribute<MenuItemAttribute>();
if (navigation == null) {
controller.GetMethods().ToList().ForEach(method =>
{
navigation = method.GetCustomAttribute<MenuItemAttribute>();
if (navigation == null) return;
if (!UserHasAccess(method.GetCustomAttribute<AuthorizedRoleAttribute>())) return;
Menu actionMenu = CreateAreaMenuItemFromAction(controller, method, navigation);
menus.Add(actionMenu);
});
return;
}
if (!UserHasAccess(controller.GetCustomAttribute<AuthorizedRoleAttribute>())) return;
Menu menu = CreateAreaMenuItemFromController(controller, navigation);
if (navigation.ParentController != null)
{
if (navigation.ParentController.IsSubclassOf(typeof(Controller)))
{
menu.ParentControllerFullName = navigation.ParentController.FullName;
submenuControllers.Add(menu);
}
}
menus.Add(menu);
});
menus = menus.Except(submenuControllers).ToList();
submenuControllers.ForEach(sm =>
{
var parentMenu = menus.FirstOrDefault(m => m.ControllerFullName == sm.ParentControllerFullName);
parentMenu?.SubMenus.Add(new SubMenu() { Name = sm.Name, Url = sm.Url });
});
return menus.OrderBy(m => m.Order).ToList();
}
And this method is called from the MenuController
which is requested from the Layout view
public class MenuController : Controller
{
public PartialViewResult Index()
{
List<Menu> menus = MenuGenerator.CreateMenu();
return PartialView("Partials/_menu", menus);
}
}
And the Menu
and SubMenu
public class Menu
{
public Menu()
{
SubMenus = new List<SubMenu>();
}
public string Name { get; set; }
public string CssIcon { get; set; }
public string Url { get; set; }
public List<SubMenu> SubMenus { get; set; }
public string ParentControllerFullName { get; set; }
public string ControllerFullName { get; set; }
public int Order { get; set; }
}
public class SubMenu
{
public string Name { get; set; }
public string Url { get; set; }
public string CssIcon { get; set; }
public int Order { get; set; }
}
The Menu view
@using DynamicMvcMenu.Models
@model List<DynamicMvcMenu.Models.Menu>
<ul class="nav">
@foreach (Menu menu in Model)
{
<li class="dropdown">
@if (string.IsNullOrWhiteSpace(menu.Url))
{
<a href="#" class="dropdown-toggle" id="dropdownCommonMenu" data-toggle="dropdown">
<span class="icon">
<i class="@menu.CssIcon" aria-hidden="true"></i>
The Result
I did not spend time in designing the style but downloaded the code from here and adjust a bit.
Possible Extensions
- You can still add your own menu (if they point to a
static
HTML file or external site) in addition to the dynamic menu just by adding into the menu list from the Menucontroller
.
- It supports only two-level menu, you can work a bit to make third-level menu of your requirement
Localization
This section is added as Gaston Verelst asked about it after giving me 5 star :)
You can add a new property LanguageKey
into the MenuItemAttribute
class and make it mandatory by changing the constructor so that it looks like
public MenuItemAttribute(string LangKey)
{
IsClickable = true;
LanguageKey = LangKey;
}
Then the Menu
and SubMenu
classes will get the name from the langauge key which can be used to retrieve the right text based on the user's language preference. You can use a Resource file or database (or anything else) to read the value for the key. You may create a new service class like the one below
LanguageService
public class LanguageService
{
public string GetText(string LangKey)
{
}
}
And in the CreateMenu
function, Name
property of Menu
and SubMenu
objects will be set by calling the GetText
method like this
menu.Name = LangaugeService.GetText(attribute.LanguageKey);
Hope this helps
How we did it
We have decided to support two langauges English and Swedish so we have added these two mandatory properties in the MeuItem attribute, i.e. the constructor with two arguments
- SwedishDefault
- EnglishDefault
We also generated a unique key dynamially (Controller fullname + action name) based on where it is applied so that we provide local dialect (same language but different text) support to the customer if they want to overwrite our default text (Say a customer/user prefers "Create New" instead of "Add" for a button)
Then the Menu gets right text (it is a dialect if that exists in database or the default one) based on the current user's preferred langugae (we have stored in cookie but it can however comes from any other source as well like database)
Finally
Hope this is useful for some who like it and need it. Please leave a comment if something is not working or if you need more information. Thank you for staying this far.