Managing Navigation Menus in Kentico Cloud
Let's find the best practice for content contributors and information architects to manage menu navigation in Kentico Cloud. As a part of this tutorial, I'm going to walk you through how to build an MVC site that will be able to render such navigation along with SEO-friendly URLs automatically resolved to pages either designed in the content inventory or dynamically rendered by traditional MVC actions.
Jan LenochPublished on Aug 1, 2017
The Business Problem
What if content contributors want to design and maintain navigation menus in Kentico Cloud? What are their requirements? Is Kentico Cloud capable of supporting such a scenario? What would be the best solution? What are the technical challenges connected to it? Let’s find out in this step-by-step article.
The Requirements
Navigation, as an important part of the information architecture of any project, should be the joint effort of multiple parties. All roles should be involved in the design process. But, as we hear from our Kentico Partners, content editors often ask for more control in certain sections of the site; like products, landing pages, knowledge-base sections, etc. Unlike traditional CMS, headless CMS systems do not have a good old content tree built in them. They allow for other types of navigation.
From discussions with our Kentico Partners, we have concluded that there are three common ways navigation items are defined.
- A navigation item points directly to a specific page (e.g. clicking a link in the menu opens a unique page).
- A navigation item is a parent of other navigation items (i.e. wraps child items).
- Part of the navigation is driven by taxonomy (e.g. product categories) or custom content element (e.g. blog post dates).
Support in Kentico Cloud
So, can Kentico Cloud support all these navigation types? As a headless CMS, Kentico Cloud is designed with openness and versatility in mind. So the short answer is: yes. You’ll find the detailed answer in the "Building the App" chapter.
The Solution Overview
I've designed my example solution in a way that is easy to understand, easy to use, extensible, yet without technical limitations or hacks.
Let's take a look at how the example app looks:
It has a top menu with a slightly hierarchical structure. The menu is collapsible.
To make the menu work I'll be working on two fronts. First, I'll create a special content type—Navigation Item—to maintain navigation in the Cloud. Then, I'll create an ASP.NET MVC Core app capable of:
- rendering menus composed of the "Navigation Item" items
- rendering pages based on URLs of hyperlinks in those menus
The following scheme clarifies what the relation between the MVC app and Kentico Cloud is. It also shows the nature of the Navigation Item content type.
The app uses our sample content—the fictional coffee-selling company "Dancing Goat". The app automatically renders a main menu built out of content items in Kentico Cloud, and it displays pages according to HTTP requests made with the menu items. Some pages are static; composed out of modular content items in the content inventory. Some of them are dynamic; i.e. ordinary MVC pages with listings and filtering based on the actual URL.
You can download the full source code of the app from GitHub.
Building the App
I’ll start in the Content models service to design the Navigation Item content type. Then, I’ll move to the content inventory to create a few items of that type. Finally, I’ll do some sweet MVC coding in Visual Studio.
Creating the Content Type
I’ll take the Dancing Goat sample project that everybody gets in their trial and free accounts. I’ll add the Navigation Item content type into that project.
A picture is worth thousand words. Let's take a look at a screenshot of the Navigation Item content type:
As you can see, you can place child navigation items into the "Child Navigation Items" element. Therefore, this concept allows you to quickly create a traditional hierarchical content tree. But you're not limited to just that content tree by any means. The Navigation Item content type actually supports all those three aforementioned types of navigation. Let’s see how you’d go about modelling them, even when combined in one single website.
Type 1: Navigation Items Point Directly to A Specific Page
You already know that you can model a hierarchy of navigation items that can be rendered as e.g. a main menu. Each item has a URL. But what happens when a user clicks a navigation item? Which page will be displayed?
To make the app display a static page, you can simply assign a content item in the "Content Item" element. For example, I'll go with this type in the "Home" navigation item.
As far as the dynamic pages are concerned, I'll touch on that in the "Type 3: Navigation Items Are Defined by Taxonomies or Custom Content Elements" chapter.
Type 2: Navigation Items Group Other Items
You may wish some navigation items just group other items. To achieve this, just create or assign several child items in the "Child Navigation Items" element. That way you'll create a hierarchy of parent and child navigation items.
You may also decide what happens when a user clicks the parent navigation item in the menu. It can either expand/collapse the child items (a submenu) using JavaScript. Alternatively, it can make a request to the server to render a page that describes the given section. In this case, you can simply assign a content item in the "Content Item" element. If you don't want the parent navigation item to point to a description page, you most probably wish to redirect the user to another navigation item instead (e.g. the first child).
In my example app, I've chosen this approach for the "Product catalog" navigation item. In the main menu, the item just expands/collapses the child item(s). But, should the user type http://example.com/product-catalog directly into the address bar, the app will redirect them to the Coffee page (instead of returning 404).
As you might guess, the "Redirect to Item" element also comes in very handy in the everyday maintenance of content—i.e. when dealing with requests for deleted, moved, or renamed content.
Type 3: Navigation Items Are Defined by Taxonomies or Custom Content Elements
With this approach, the app filters content based on taxonomy or custom content element values. For example, a URL http://example.com/shop/phones/apple may filter for phone content items with the Brand element set to Apple. Or, a URL http://example.com/blog/2014/10 may make the app filter for blog post items published in October 2014.
In short, we call this type of navigation a faceted navigation.
The first example (phones) would be best implemented with the Kentico Cloud taxonomy feature. In this case, the app would just have to get the taxonomy structure off of the Delivery/Preview API and add it to the whole navigation hierarchy as a subtree of the "/shop" item. Note: While taxonomy is really useful, currently it does not allow us to store the URL-friendly code names of taxonomy items. So I've omitted the implementation of this type of faceted navigation in this article.
The second example (blog posts) would require the app knowing during which years and months blog posts were created. The app would have to fetch the dates of existing blog posts, distil years and months, and add them under the "/blog" navigation item. I'll demonstrate this second type of faceted navigation in my app.
As there can be dozens of implementations of that second type of faceted navigation, I've decided to implement it with code. I didn’t incorporate any broad support for faceted navigation into the Navigation Item content type. My idea was the following:
- Developers could first agree upon a list of starting URLs with the content strategists. An example of a starting URL is "/blog".
- Then, the starting URLs would be placed into the navigation hierarchy by the content strategists as ordinary navigation items.
- Finally, when displaying the menu, the app would add child navigation items dynamically, using arbitrary code, according to specific business requirements.
Such an idea has the following advantages:
- The content strategists are free to place these starting points anywhere in the navigation hierarchy.
- They are free to move them around over time.
- You, developers, are free to use code to make the app add the dynamic navigation items.
Alright; until now, I've dealt with how the navigation items will be added to the menus. Now, how about displaying content according to these menu items? Short answer: In the same way—using code. In the case of MVC, the incoming requests are best handled with the ordinary routes and controller actions.
Let's get back to my example app. I'll set a "Blog" navigation item as the starting point of a simple faceted navigation. I'll set the "Redirect To URL" element to "/blog". Then, I'll create a class that generates the child navigation items dynamically, based on the "Post date" elements of all the "Article" content items in the inventory. To demonstrate the flexibility of using code (as opposed to using a drag-and-drop tool), I'll make the class capable of generating either a hierarchical branch of navigation items or a flat one. The hierarchical one would look like this:
- Blog (/blog)
- 2014(/blog/2014)
- 10 (/blog/2014/10)
- 11 (/blog/2014/11)
- 2014(/blog/2014)
The flat one would be:
- Blog (/blog)
- October 2014 (/blog/2014/10)
- November 2014 (/blog/2014/11)
As mentioned above, the process of routing the URLs into dynamic pages will be handled by clean and conventional MVC routes and controller actions. The following is a signature of the Index method of the BlogController class:
public async Task<ActionResult> Index(int? year, int? month)
Alright, so far, I've outlined the basics of the solution. Let's jump into the implementation itself!
Creating Navigation Items
For the purpose of this article, I’ll create the following simple navigation structure. (In parentheses, there's always the URL slug.)
- Navigation ("[root]")
- Home ("")
- Product catalog ("product-catalog")
- Coffee ("coffee")
- Blog ("blog")
- October 2014
- November 2014
The "Home" and "Coffee" items will point to static content. For reference, you can take a look at the "Home" item screenshot in the "Type 1: Navigation Items Point Directly to A Specific Page" chapter above.
Note: To wrap several coffee products into one content item, I've created a simple "Content Listing" content type. It has just one modular content element to hold the other items. It defines the layout of the page and hence can be reused.
The “Product catalog” and "Blog" items will expand and collapse their children using JavaScript. Upon navigating to "/product-catalog" manually by the user, the app will redirect to the first child, i.e. to "/product-catalog/coffee". Again, for reference, you can take a look at the screenshot of the "Product Catalog" navigation item in the "Type 2: Navigation Items Group Other Items" chapter above.
The child items of the "Blog" item will be generated dynamically, based on creation dates of all "Article" content items. Upon navigating to "/blog", "/blog/2014", "/blog/2014/10, or "/blog/2014/11", the controller action will dynamically filter for the articles and it will render a corresponding listing. The "Blog" item is an ordinary navigation item, with only the "Redirect to URL" element set to "/blog". That's it.
Coding the MVC App
Assuming that you're familiar with MVC, I'll point out the crucial parts of the code. As the code is not too complex, you'll be able to learn the implementation details either from the in-line code comments or from the code itself.
To start, I'll use the "dotnet new" command with the KenticoCloud.CloudBoilerplateNet template installed. It will add dependencies and prepare some common things for me.
Rendering the Menu
I'll create a main menu using the acclaimed Drawer menu project. This collapsible menu project represents best practice in front-end menu development. It can cope with just a single HTML markup for all viewports, it uses minimal JS dependencies (jQuery, iScroll) and its code files have been distributed over a CDN network.
I'll define a section for the navigation markup in my Layout.cshtml file, and then I'll plug in a DrawerMenuPartial.cshtml partial view to the output. The view is strongly typed on the NavigationItem type that represents the "Navigation Item" Kentico Cloud content type.
The _DrawerMenuPartial.cshtml file:
@model NavigationItem
<header class="drawer-navbar drawer-navbar--fixed" role="banner">
<div class="drawer-container">
<div class="drawer-navbar-header">
<a class="drawer-brand" href="/">Dancing Goat</a>
<button type="button" class="drawer-toggle drawer-hamburger">
<span class="sr-only">toggle navigation</span>
<span class="drawer-hamburger-icon"></span>
</button>
</div>
<nav class="drawer-nav" role="navigation">
<ul class="drawer-menu ">
@foreach (var item in Model.ChildNavigationItems)
{
@Html.DisplayFor(vm => item, "NavigationItemMain-1")
}
</ul>
</nav>
</div>
</header>
Note: On line 16, I use an explicit display template to render the first level of the hierarchy.
By looking at the NavigationItemMain-1.cshtml file, you'll notice that I do basically the same thing for the second level of the hierarchy of navigation items.
@model NavigationItem
@if (Model.ChildNavigationItems == null || !Model.ChildNavigationItems.Any())
{
<li><a class="drawer-menu-item" href="/@Model.UrlPath">@Html.DisplayFor(vm => vm.Title)</a></li>
}
else
{
<li class="drawer-dropdown">
<a class="drawer-menu-item" href="/@Model.UrlPath" data-toggle="dropdown" role="button" aria-expanded="false">
@Html.DisplayFor(vm => vm.Title)
<span class="drawer-caret" />
</a>
<ul class="drawer-dropdown-menu">
@foreach (var item in Model.ChildNavigationItems)
{
@Html.DisplayFor(vm => item, "NavigationItemMain-2")
}
</ul>
</li>
}
Now, how can I obtain that NavigationItem object? There's no magic; I'll just create a NavigationProvider helper class to get the data from the Delivery/Preview API, decorate it with some computed values and store it in an in-memory cache of the app. The class uses a separate cache instance than that of the CachedDeliveryClient class in our boilerplate code. This is beneficial—the navigation data can be cached for either a shorter or longer period of time, based on your needs.
(On a side note: Speaking of computed properties, one of them—the AllParents—can be used to render breadcrumb menus.)
The most important method of the NavigationProvider class is GetNavigationAsync. It orchestrates the process mentioned in the previous paragraph. You can supply an explicit code name of a navigation item and the preferred depth to load via the two optional parameters. If you don't specify them, the method will use the defaults set in the appsettings.json obtained through the NavigationOptions class. The explicit code name parameter is there to allow me to have other menus in the app (bottom menu, site map page etc.).
/// <summary>
/// Gets the root <see cref="NavigationItem"/> item either off of the <see cref="IMemoryCache"/> or the Delivery/Preview API endpoint.
/// </summary>
/// <param name="navigationCodeName">The explicit codename of the root item. If <see langword="null" />, the value supplied in the constructor is taken.</param>
/// <param name="maxDepth">The explicit maximum depth of the hierarchy to be fetched</param>
/// <returns>The root item of either the explicit codename, or default codename</returns>
public async Task<NavigationItem> GetNavigationAsync(string navigationCodeName = null, int? maxDepth = null)
{
string cn = navigationCodeName ?? _navigationCodename;
int d = maxDepth ?? _maxDepth;
return await _cache.GetOrCreate(NAVIGATION_CACHE_KEY, async entry =>
{
var navigation = await LoadNavigationItemsAsync(cn, d);
var emptyList = new List<NavigationItem>();
// Add the UrlPath property values to the navigation items first.
AddUrlPaths(emptyList, navigation, string.Empty);
emptyList.Clear();
// Then, add the RedirectPath, Parent, and AllParents property values. UrlPath value is needed for that, hence a separate iteration through the hierarchy.
AddRedirectPathsAndParents(navigation, emptyList, navigation);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_navigationCacheExpirationMinutes);
return navigation;
});
}
So, to get the NavigationItem object, I could just call that method from within my controller action, pass it along to a view through a view model, and I'd be done.
await _navigationProvider.GetNavigationAsync()
But wait! I want my additional menu items for the faceted navigation. So, I'll pass the results to the GenerateItemsAsync method (see the next chapter for details).
var navigation = await _menuItemGenerator.GenerateItemsAsync(await _navigationProvider.GetNavigationAsync());
Before we move on, let's take a look at the remaining important methods of the NavigationProvider class:
public async Task<NavigationItem> LoadNavigationItemsAsync(string navigationCodeName = null, int? maxDepth = null)
{
string cn = navigationCodeName ?? _navigationCodename;
int d = maxDepth ?? _maxDepth;
var response = await _client.GetItemsAsync<NavigationItem>(
new EqualsFilter("system.type", ITEM_TYPE),
new EqualsFilter("system.codename", cn),
new LimitParameter(1),
new DepthParameter(d)
);
return response.Items.FirstOrDefault();
}
private void AddUrlPaths(IList<NavigationItem> processedParents, NavigationItem currentItem, string pathStub)
{
if (processedParents == null)
{
throw new ArgumentNullException(nameof(processedParents));
}
if (currentItem == null)
{
throw new ArgumentNullException(nameof(currentItem));
}
// Check for infinite loops.
if (!processedParents.Contains(currentItem))
{
AddUrlPath(currentItem, pathStub);
processedParents.Add(currentItem);
// Spawn a tree of recursions.
foreach (var currentChild in currentItem.ChildNavigationItems)
{
AddUrlPaths(processedParents, currentChild, currentItem.UrlPath);
}
}
}
private void AddRedirectPathsAndParents(NavigationItem cachedNavigation, IList<NavigationItem> processedParents, NavigationItem currentItem)
{
if (currentItem == null)
{
throw new ArgumentNullException(nameof(currentItem));
}
// Check for infinite loops.
if (!processedParents.Contains(currentItem))
{
var redirect = currentItem.RedirectToItem.FirstOrDefault();
if (redirect != null)
{
currentItem.RedirectPath = GetRedirectPath(cachedNavigation, redirect);
}
currentItem.Parent = processedParents.Count > 0 ? processedParents.Last() : null;
currentItem.AllParents = processedParents;
processedParents.Add(currentItem);
// Spawn a tree of recursions.
foreach (var currentChild in currentItem.ChildNavigationItems)
{
AddRedirectPathsAndParents(cachedNavigation, processedParents, currentChild);
}
}
}
private void AddUrlPath(NavigationItem navigationItem, string pathStub)
{
if (navigationItem == null)
{
throw new ArgumentNullException(nameof(navigationItem));
}
if (navigationItem.UrlSlug != _rootToken && navigationItem.UrlSlug != _homepageToken)
{
navigationItem.UrlPath = !string.IsNullOrEmpty(pathStub) ? $"{pathStub}/{navigationItem.UrlSlug}" : navigationItem.UrlSlug;
}
else
{
navigationItem.UrlPath = string.Empty;
}
}
private string GetRedirectPath(NavigationItem cachedNavigation, NavigationItem itemToLocate)
{
if (cachedNavigation == null)
{
throw new ArgumentNullException(nameof(cachedNavigation));
}
if (itemToLocate == null)
{
throw new ArgumentNullException(nameof(itemToLocate));
}
if (cachedNavigation.UrlPath == null)
{
throw new ArgumentException($"The {nameof(cachedNavigation.UrlPath)} property cannot be null.", nameof(cachedNavigation.UrlPath));
}
var match = cachedNavigation.ChildNavigationItems.FirstOrDefault(i => i.System.Codename == itemToLocate.System.Codename);
if (match != null)
{
return match.UrlPath;
}
else
{
return cachedNavigation.ChildNavigationItems.Select(i => GetRedirectPath(i, itemToLocate)).FirstOrDefault(r => !string.IsNullOrEmpty(r));
}
}
Dynamic Menu Item Generation
As mentioned above, the faceted navigation requires the app to scan the page body data (in our case, the "Post date" elements of all "Article" content items) to distill a set of navigation items. These items, when used later to do a browser request, will cause the app to properly filter for articles and return them on a listing page.
So, I'll create a MenuItemGenerator class for that. In it, I'll have two important members:
- a dictionary of starting URLs (like "/blog" in my case) and their corresponding menu item generation methods
- a central method that invokes all those menu item generation methods
The dictionary looks like this:
private Dictionary<string, Func<NavigationItem, string, Task<NavigationItem>>> _startingUrls = new Dictionary<string, Func<NavigationItem, string, Task<NavigationItem>>>();
(It will get populated with a "blog" relative URL and the delegate to the GenerateNavigationWithBlogItemsAsync method in the constructor.)
The central method:
/// <summary>
/// Wraps all methods that generate additional navigation items.
/// </summary>
/// <param name="sourceItem">The original root navigation item</param>
/// <returns>A copy of the <paramref name="sourceItem"/> with additional items</returns>
public async Task<NavigationItem> GenerateItemsAsync(NavigationItem sourceItem)
{
return await _cache.GetOrCreateAsync("generatedNavigationItems", async entry =>
{
foreach (var url in _startingUrls)
{
sourceItem = await url.Value(sourceItem, url.Key);
}
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_navigationCacheExpirationMinutes);
return sourceItem;
});
}
The point of the MenuItemGenerator class is to re-generate the hierarchy produced by the above mentioned NavigationProvider.GetNavigationAsync method and add a few dynamic items. But, since I generate the hierarchy just for the purpose of rendering the menu (not for routing the incoming requests), my ProcessLevelForBlog private method doesn't bother with re-generating the NavigationItem objects with all its original properties. It just produces lightweight copies of the original objects with just a few properties.
/// <summary>
/// Traverses the original hierarchy, creates a lightweight clone of it (not all original properties), and adds <see cref="NavigationItem"/> items for the Blog section.
/// </summary>
/// <param name="currentItem">The root <see cref="NavigationItem"/></param>
/// <param name="yearsMonths">Dictionary of years and months of existing "Article" content items</param>
/// <param name="startingUrl">The starting URL where new year and month <see cref="NavigationItem"/> items will be added</param>
/// <param name="flat">Flag indicating whether flat structure like "October 2014" should be generated</param>
/// <param name="processedParents">Collection of processed parent navigation items</param>
/// <returns>A copy of <paramref name="currentItem"/> with generated Blog navigation items</returns>
private NavigationItem ProcessLevelForBlog(NavigationItem currentItem, IDictionary<YearMonthPair, string> yearsMonths, string startingUrl, bool flat, IList<NavigationItem> processedParents)
{
processedParents = processedParents ?? new List<NavigationItem>();
var newItem = new NavigationItem();
// Check for infinite loops.
if (!processedParents.Contains(currentItem))
{
// Add only those properties that are needed to render the menu (as opposed to routing the incoming requests).
newItem.Title = currentItem.Title;
newItem.UrlPath = currentItem.UrlPath;
newItem.AllParents = processedParents;
processedParents.Add(currentItem);
// The "/blog" item is currently being iterated over.
if (currentItem.UrlPath.Equals(startingUrl, StringComparison.OrdinalIgnoreCase))
{
// Example of a flat variant: "October 2014", "November 2014" etc.
if (flat)
{
var items = new List<NavigationItem>();
items.AddRange(yearsMonths.OrderBy(k => k.Key, new YearMonthComparer()).Select(i => GetItem(startingUrl, i.Value, i.Key.Year, i.Key.Month)));
newItem.ChildNavigationItems = items.ToList();
}
// Example of a deep variant:
// "2014"
// "10"
// "11"
else
{
var yearItems = new List<NavigationItem>();
// Distill years of existing "Article" items.
foreach (var year in yearsMonths.Distinct(new YearEqualityComparer()))
{
var yearItem = GetItem(startingUrl, null, year.Key.Year);
yearItems.Add(yearItem);
var monthItems = new List<NavigationItem>();
// Distill months.
foreach (var month in yearsMonths.Keys.Where(k => k.Year == year.Key.Year).OrderBy(k => k.Month))
{
monthItems.Add(GetItem(startingUrl, null, year.Key.Year, month.Month));
}
yearItem.ChildNavigationItems = monthItems;
}
newItem.ChildNavigationItems = yearItems;
}
}
else
{
newItem.ChildNavigationItems = currentItem.ChildNavigationItems.Select(i => ProcessLevelForBlog(i, yearsMonths, startingUrl, flat, processedParents)).ToList();
}
return newItem;
}
return null;
}
Note: The method can also be invoked with a "flat" boolean parameter set to false, which would produce a deep hierarchy, instead of a shallow one. As the Drawer menu project does not support deeper menus, I'll set "flat" to true.
Alright. I have my navigation items cached in the app and I've generated a copy of the hierarchy with additional items for faceted navigation. Now, I can display that data through whatever controller method I wish. For example, in my app, I'll create two controllers:
In these controllers, I'll not only display the menus, but, I'll also have to deal with requests made from within these menus.
Processing the HTTP Requests
I'll explain the logic in the order of the incoming request. From routes, controller actions, to action results.
To start with the routes, I'll put these two into the Startup.cs code file.
app.UseMvc(routes =>
{
routes.MapRoute(
name: "facetedNavigation",
template: "blog/{year?}/{month?}",
defaults: new { controller = "Blog", action = "Index" });
routes.MapRoute(
name: "staticContent",
template: "{*urlPath}",
defaults: new { controller = "StaticContent", action = "Index" },
constraints: new { urlPath = new StaticContentConstraint(app.ApplicationServices.GetRequiredService<IContentResolver>()) });
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
The "facetedNavigation" route defaults to the BlogController class, whereas the "staticContent" points to the StaticContentController. The order of the two routes does not really matter, as long as there aren't any URLs in the app that could potentially be resolved by both controllers. In such a case, the first matching route/controller would serve the request.
The StaticContentConstraint is there just to allow MVC to fall back to the "default" route, in cases of requests for non-existent static content (not handled by redirects; see the "Type 2: Navigation Items Group Other Items" chapter above). If you remove the constraint from the "staticContent" route definition, the app will return 404's right off the bat, without even trying to match the request through the "default" route.
The BlogController Class
I'll create this class to demonstrate the traditional MVC approach to rendering a listing of the "Article" content items. It showcases the faceted navigation.
The point of the class is to show how quickly you can get the page body data, the navigation data, and how you'd display them through a view model.
public async Task<ActionResult> Index(int? year, int? month)
{
List<IQueryParameter> filters = new List<IQueryParameter>();
filters.AddRange(new IQueryParameter[]
{
new EqualsFilter("system.type", TYPE_NAME),
new DepthParameter(0),
new OrderParameter(ELEMENT_NAME)
});
string yearString = null;
string monthString = null;
if (year.HasValue && !month.HasValue)
{
yearString = $"{year}-01";
monthString = $"{year + 1}-01";
}
else if (year.HasValue && month.HasValue)
{
if (month < 12)
{
yearString = $"{year}-{GetMonthFormatted(month.Value)}";
monthString = $"{year}-{GetMonthFormatted(month.Value + 1)}";
}
else
{
yearString = $"{year}-12";
monthString = $"{year + 1}-01";
}
}
if (year.HasValue)
{
filters.Add(new RangeFilter(ELEMENT_NAME, yearString, monthString));
}
var pageBody = await _deliveryClient.GetItemsAsync<Article>(filters);
var navigation = await _menuItemGenerator.GenerateItemsAsync(await _navigationProvider.GetNavigationAsync());
var pageViewModel = new PageViewModel
{
Navigation = navigation,
Body = pageBody.Items
};
return View(DEFAULT_VIEW, pageViewModel);
}
The StaticContentController Class
This class will showcase how the app can render content items modeled and composed in the content inventory automatically. It routes and resolves the SEO-friendly URLs to pages using MVC display templates.
Let me touch on MVC templates for a second. In one sentence, MVC templates are the same as partial views, with the advantage of being rendered by MVC automatically just by a naming convention, any time data of a known type appears in the view model. As long as a template for a type exists in a certain folder (like ~/Views/Shared/DisplayTemplates/[type name].cshtml), all that's needed to make MVC render the HTML is to pass data of that type into an arbitrary view as a view model. It doesn't even matter if the data of that type is hidden deeply inside of the model; MVC is smart enough to render it anyways. Templates can also be invoked explicitly. This is handy as it is not always desired to render a type with just a single HTML output throughout the whole website. Instead, you might want to have a different output in different sections of the site, or in different contexts. In my example, I don't set multiple contexts. But if you're curious about how I'd go about that, it could be done by establishing these contexts in the display templates of the layouts as suffixes of the display template names (e.g. in the ContentListing.cshtml file and others). The ContentListing.cshtml could then invoke templates with the "Listing" suffix in their names.
Let's get back to the controller. I won't have much code in it. Controllers should be kept rather simple. Instead, I'll just call the ContentResolver.ResolveRelativeUrlPathAsync method from within my controller. Depending on the ContentResolverResults returned by that method call, the remaining controller code will decide on what derived type of the ActionResult it returns to the browser. Should the results contain a code name of a content item, the controller will get that content in an ordinary fashion and return a ViewResult. In the case of redirects, it may return RedirectPermanent. That's pretty much about the controller itself.
The ContentResolverResults class:
using System.Collections.Generic;
namespace NavigationMenusMvc.Models
{
public class ContentResolverResults
{
public bool Found { get; set; }
public IEnumerable<string> ContentItemCodenames { get; set; }
public string ViewName { get; set; }
public string RedirectUrl { get; set; }
}
}
The StaticContentController class:
public async Task<ActionResult> Index(string urlPath)
{
ContentResolverResults results;
try
{
results = await _contentResolver.ResolveRelativeUrlPathAsync(urlPath);
}
catch (Exception ex)
{
return new ContentResult
{
Content = $"There was an error while resolving the URL. Check if your URL was correct and try again. Details: {ex.Message}",
StatusCode = 500
};
}
if (results != null)
{
if (results.Found)
{
if (results.ContentItemCodenames != null && results.ContentItemCodenames.Any())
{
return await RenderViewAsync(results.ContentItemCodenames, results.ViewName);
}
else if (!string.IsNullOrEmpty(results.RedirectUrl))
{
return LocalRedirectPermanent($"/{results.RedirectUrl}");
}
}
else if (!string.IsNullOrEmpty(results.RedirectUrl))
{
return RedirectPermanent(results.RedirectUrl);
}
}
return NotFound();
}
The interesting part comes in the ContentResolver helper class.
The ContentResolver Class
This class will be responsible for:
- either finding the codename of the content item required for rendering of the page body
- or finding the proper URL to redirect to
The ResolveRelativeUrlPathAsync method is the main entry point that orchestrates that job. It works in a way that it first splits the relative path obtained from the incoming HTTP request into several URL slugs. Then, it iterates over these slugs (from left to right) and tries to find matching navigation items in the cached hierarchy. Once it hits the last slug in the whole path (the right-most one), it tries to return either the code name or the redirect path.
I think it is best to learn the implementation from the comments in the code itself:
#region "Public methods"
/// <summary>
/// Resolves the relative URL path into <see cref="ContentResolverResults"/> containing either the codenames of content items, or a redirect URL.
/// </summary>
/// <param name="urlPath">The relative URL from the HTTP request</param>
/// <returns>The <see cref="ContentResolverResults"/>. If Found is true and the RedirectUrl isn't empty, then it means a local redirect to a static content URL.</returns>
public async Task<ContentResolverResults> ResolveRelativeUrlPathAsync(string urlPath, string navigationCodeName = null, int? maxDepth = null)
{
string cn = navigationCodeName ?? _navigationCodename;
int d = maxDepth ?? _maxDepth;
// Get the 'Navigation' item, ideally with "depth" set to the actual depth of the menu.
var navigationItem = await _navigationProvider.GetNavigationAsync(cn, d);
// Strip the trailing slash and split.
string[] urlSlugs = NavigationProvider.GetUrlSlugs(urlPath);
// Recursively iterate over modular content and match the URL slugs for the each recursion level.
return await ProcessUrlLevelAsync(urlSlugs, navigationItem, _rootLevel);
}
/// <summary>
/// Gets the codenames of <see cref="IEnumerable{Object}"/> content items using <see cref="System.Reflection"/>.
/// </summary>
/// <param name="contentItems">The shallow content items to be fetched again using their codenames</param>
/// <returns>The codenames</returns>
public static IEnumerable<string> GetContentItemCodenames(IEnumerable<object> contentItems)
{
if (contentItems == null)
{
throw new ArgumentNullException(nameof(contentItems));
}
var codenames = new List<string>();
foreach (var item in contentItems)
{
ContentItemSystemAttributes system = item.GetType().GetTypeInfo().GetProperty("System", typeof(ContentItemSystemAttributes)).GetValue(item) as ContentItemSystemAttributes;
codenames.Add(system.Codename);
}
return codenames;
}
#endregion
Note about the ResolveContentAsync private method: As you already know, the Navigation Item content type allows us to redirect to another navigation item that in turn also redirects to another one, etc. It is this method that computes the ultimate (final) redirect URL.
The results of the processing of the ContentResolver class is again wrapped in a simple ContentResolverResults object.
I've decided to set a convention for the results: Should a redirect be returned, the inner "Found" boolean property tells the client code whether it was a redirect to a static content item or something else (to a fixed relative URL like "/blog" or an absolute external URL). That way, my controller code may return a safer LocalRedirectPermanent instead of just RedirectPermanent in certain situations.
And now we're done! Believe it or not, this is all you need to allow an MVC app to automatically display user-editable menus and user-editable pages from Kentico Cloud.
Get the Code
You can download the full source of the app from a common repository of article examples in GitHub. The folder also contains the Delivery/Preview API dump of the Kentico Cloud project of the example app. With that file, you can quickly inspect the structure of the content.
Summary
I've explained that a flexible navigation structure can be modeled using Kentico Cloud content types. Then, you've learned how the three frequently used types of navigation can be implemented with these content types. Finally, I've shown a simple MVC code that allows an app to automatically reflect the navigation and content designed in Kentico Cloud. With such approach and design, both the content strategists and developers are no longer required to do each others' jobs and they can focus on what matters to them most.