Developing a drag and droppable menu (MVC)
The main menu is usually a quite important part of your website. A traditional top/main menu typically displays section pages, and can easily be done with EPiServer. But what if you would like the menu items to be drag and droppable, and possible non-hierarchical? I thought I’d share some code on how to use a content area as a main menu, which will enable editors to drag and drop pages into the menu and customize it with ease.
In order to accomplish this, I’ve created a controller which convert pages into view models. The view models are then rendered inside a content area which is located in the header view of my site.
First of, the menu item model. This is pretty basic, with string properties for url and heading, and a bool property to set whether the link is currently selected or not.
1: public class MenuItemViewModel
2: {
3: public string Url { get; set; }
4: public string Heading { get; set; }
5: public bool Active { get; set; }
6: }
Secondly, the controller. To be able to make this work with all page types, I have set the PageController typed to SitePageData, which in this case is a base class for all page types. As for the TemplateDescriptor, I’ve set the TemplateTypeCategory to MvcPartialController. This way, it will render as a partial view. I’ve also set Tags to “mainmenu”, so that it will only match content areas with that certain tag. Inherited is set to “true” so that it will match all types that inherit from SitePageData. I also needed to make sure that menu items are active when a child page is selected, but since this is a (possible) non-hierarchical menu, I also needed to make sure that only the closest menu item is set as active, as a page might be a descendent of several menu items. I’ve tried my best to describe this in the inline comments.
1: [TemplateDescriptor(
2: TemplateTypeCategory = TemplateTypeCategories.MvcPartialController,
3: Tags = new[] { "mainmenu" }, Inherited = true)]
4: public class MenuItemController : PageController<SitePageData>
5: {
6: public ActionResult Index(SitePageData currentPage)
7: {
8: // get the currently loaded page
9: var currentContextPage = ControllerContext.RequestContext.GetContentLink();
10: bool active;
11:
12: // check if the current menu item page is the same as the page in the context
13: active = currentContextPage.ID == currentPage.PageLink.ID;
14: if (!active)
15: {
16: // not the same page, we'll need to check if the page is on of the descendents
17: // (to see if one of the child pages is currently selected)
18: var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
19: List<ContentReference> pagePath = contentLoader.GetAncestors(currentContextPage)
20: .Reverse()
21: .Select(x => x.ContentLink)
22: .SkipWhile(x => !x.CompareToIgnoreWorkID(currentPage.PageLink))
23: .ToList();
24: pagePath.Add(currentContextPage);
25:
26: // we also need to check if the page in context is a child to
27: // another page in the menu, as we only want one active menu point at a time.
28: // As an example, if we skipped this part,
29: // the start page menu point would always be set as active
30: var startPage = contentLoader.Get<StartPage>(ContentReference.StartPage);
31: List<IContent> menuPages = startPage.MenuArea.Contents.ToList();
32:
33: ContentReference firstMatch = pagePath.Join(menuPages,
34: p => p, m => m.ContentLink, (p, m) => p).LastOrDefault();
35:
36: // if a match is found, active will be set to true
37: active = firstMatch == currentPage.PageLink;
38: }
39: var menuItem = new MenuItemViewModel()
40: {
41: // mark as active if the current menu item page equals the page in context,
42: // or if the current page is one of the descending pages
43: Active = currentPage.PageLink.ID == currentContextPage.ID || active,
44: Url = currentPage.LinkURL,
45: Heading = currentPage.PageName
46: };
47: return PartialView(menuItem);
48: }
49: }
In the corresponding view, a link is rendered based on the properties in the model, and the Active property is used to add a css class. If active, a sky blue background should be displayed.
1: @model MenuItemViewModel
2: <a href="@Url.PageUrl(Model.Url)" class="@(Model.Active ? "active" : null)">
3: @Model.Heading
4: </a>
At this point, drag and drop of pages will work if the current content area is placed within a page view. However, since this should be used as a top menu, I want to put it in my header view. In order to accomplish this, I’ve created a layout model, which will be accessible for partial views such as the header or footer. This will contain a ContentArea property, which will be populated by a ContentArea property on the StartPage. I’ve chosen to do it this way to keep it simple, but you could take a look at the Alloy MVC templates(highly recommended!) to see a different, more dynamic approach on how to handle this. Please note that in a real-world scenario, the model would most likely contain more properties.
The (really, really simple) layout model:
1: public class LayoutModel
2: {
3: public ContentArea Area { get; set; }
4: }
Then I’ve created a base class for all my PageControllers. This will make sure the layout model is populated with the correct data, no matter which page you are currently on.
1: public abstract class PageControllerBase<T> : PageController<T> where T : SitePageData
2: {
3: protected override void OnActionExecuted(ActionExecutedContext filterContext)
4: {
5: // get the start page
6: var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
7: var startPage = contentLoader.Get<StartPage>(ContentReference.StartPage);
8: // populate the layout model with data from the start page
9: var layoutModel = new LayoutModel()
10: {
11: Area = startPage.MenuArea
12: };
13: // populate viewdata with layout model
14: filterContext.Controller.ViewData["LayoutModel"] = layoutModel;
15: base.OnActionExecuted(filterContext);
16: }
17: }
And in my header view, I’ll render the ContentArea property. I’ve added some custom tags and css classes in order to make it look nice in both edit mode and view mode (you can read more about those settings here). Also notice that I set the Tag to “mainmenu”.
1: @{ var layoutModel = ViewData["LayoutModel"] as LayoutModel; }
2:
3: other header stuff here goes here
4:
5: @Html.PropertyFor(x => layoutModel.Area, new
6: {
7: Tag = "mainmenu", CustomTag = "ul" , CssClass = "nav navbar-nav pull-right",
8: ChildrenCustomTagName = "li", EditContainerClass = "nav navbar-nav pull-right"
9: })
In order to get on-page-editing working, connections are added from the page model to my view model. I do this in the start page controller, since my property is located in my StartPage type.
1: public class StartController : PageControllerBase<StartPage>
2: {
3: public ActionResult Index(StartPage currentPage)
4: {
5: var editHints = ViewData.GetEditHints<LayoutModel, StartPage>();
6: editHints.AddConnection(m => m.Area, p => p.MenuArea);
7: return View(currentPage);
8: }
9: }
Final result – view mode:
And in edit mode:
Keep in mind that since a content area is used, you have the possibility to extend the menu options by using blocks. How about a DropDownMenuItemBlock? Or maybe a MegaMenuItemBlock?
Haven't thought of using a ContentArea like that. Nice one!
Nice one! Thanks for sharing.