November Happy Hour will be moved to Thursday December 5th.
November Happy Hour will be moved to Thursday December 5th.
This topic introduces some options for building content navigation in Optimizely CMS. You can, for example, build navigation using link collections, structure-based visual components like menus and breadcrumbs, or search-based navigation and filtering.
In this topic
A Link Collection is often used for things like related content or lists of links in the page footer. Since link collections are editorial content, this data needs to be stored in a user-defined property.
In this example, you add a property of the type LinkItemCollection to a content type named ArticlePage to enable editors to build a list of links that can be both internal or external. You could also use the type IList<ContentReference> instead of LinkItemCollection if you only reference internal content.
[ContentType( ... )]
public class ArticlePage : PageData
{
[CultureSpecific]
public virtual string TeaserText { get; set; }
[CultureSpecific]
public virtual XhtmlString MainBody { get; set; }
[CultureSpecific]
public virtual LinkItemCollection RelatedContentLinks { get; set; }
}
Add a page controller and then use Html.PropertyFor to render the list of links in your page template. The default rendering for LinkItemCollection will be an unordered list using ul, li and a tags.
@model EpiDemo.Models.Pages.ArticlePage
<!DOCTYPE html>
<html>
<head>
<title>@Model.Name</title>
</head>
<body>
<h1>@Html.PropertyFor(m => m.Name, new { customTag = "span" })</h1>
@Html.PropertyFor(m => m.MainBody)
<h2>Releated Content</h2>
@Html.PropertyFor(m => m.RelatedContentLinks)
</body>
</html>
You can take full control of rendering when the built-in way does not work for you. As a simple example, you can replace PropertyFor in the example above with your own logic, and iterate the collection yourself in the View.
...
@Html.PropertyFor(m => m.MainBody)
// FullRefreshPropertiesMetaData asks on-page edit to reload the page
// to run the following custom rendering again after the value has changed.
@Html.FullRefreshPropertiesMetaData(new []{ "RelatedContentLinks" })
// EditAttributes enables on page-edit when you have custom rendering.
<p @Html.EditAttributes(m => m.CurrentPage.RelatedContentLinks) >
@if (Model.CurrentPage.RelatedContentLinks != null)
{
<span>See also:</span>
foreach (LinkItem item in Model.CurrentPage.RelatedContentLinks)
{
<a href="@UrlResolver.Current.GetUrl(item.Href)">@item.Text</a> }
}
</p>
...
Note: Avoid adding custom rendering of properties directly into your page templates. When you do, make sure you use EditAttributes and FullRefreshPropertiesMetaData. We strongly recommend that you learn how to use DisplayTemplates and UIHint as described below instead. This will make it easier for editors to work with your templates, since it keeps the on-page editing experience working smoothly.
A better way to build custom rendering is to move it into a Display Template. Add a new partial template with the name SeeAlso.cshtml in your DisplayTemplates folder (/Shared/DisplayTemplates/SeeAlso.cshtml), and move your custom rendering from your page template. Set the model to be the same type as the property type.
@using EPiServer.SpecializedProperties
@model LinkItemCollection
@if (Model != null)
{
<p>
See also:
@foreach (LinkItem item in Model)
{
<a href="@item.Href">@item.Text</a>
}
</p>
}
Then, restore your rendering in your page template to use PropertyFor with an extra parameter that contains the name of the display template. On-page editing will work as expected and preview will be correct without forced page reloads.
...
@Html.PropertyFor(m => m.MainBody)
<h2>Releated Content</h2>
@Html.PropertyFor(m => m.CurrentPage.RelatedContentLinks, "SeeAlso")
...
Alternatively, add a UIHint attribute to your property definition in your content model and specify the name of your display template for custom rendering.
public class ArticlePage : PageData
{
...
[UIHint("SeeAlso")]
public virtual LinkItemCollection RelatedContentLinks { get; set; }
...
Now, whenever you use Html.PropertyFor to render this property in a template, it uses your custom rendering by default.
If you want to use a Link Collection in, for example, your page footer, you can use a similar approach. The main difference is that you no longer want this data stored on each page; instead, you want to edit it once and see the page footer updated on all pages.
Common patterns to accomplish this to either add a user-defined property to the site's start page or to have a special content type just for site settings. You then load this content item and put it into your view model and use it from your layout.
There are many ways to build website navigations. A common method in Optimizely CMS is to use the hierarchy of pages to construct lists and navigation. For the Alloy sample site, the Top menu is constructed from all the children of the start page. Breadcrumbs renders a list of all ancestors (parents) of a page in the hierarchy up to the start page. A Hierarchical Menu (or Submenu) mirrors a part of the page hierarchy. A List of Pages is often created by listing all children of a specific page or through a search. There are also data types that can be used to allow editors to construct arbitrary collection of links.
Menus can be built programatically using collections of pages gathered through GetPage(ContentReference), GetChildren(ContentReference) and other methods of the IContentLoader service. When you have retrieved an instance of a page, you have access to all values both user defined and build in.
All content that inherits from PageData has a built-in boolean property named VisibleInMenu. The intended use of this property is to enable an editor to hide pages from menus and breadcrumbs. There is no built-in logic for how this should work in your templates built; it is up to you as a developer to implement this functionality. There are a few examples below how this can be done.
To illustrate listings, we start by creating a simple list of pages that are children to the current page.
There are several ways of passing data to views. A view model is useful if you want to pass typed data beyond your content into your views. You can define a model type in the view, and then pass an instance of this type to the view from the action method.
We use a simple class in this example but in a real-world site, it is common to use base classes, interfaces, and generic types to make it easier to access the view model from your shared layout views. See Content model and views.
public class ArticlePageViewModel
{
public IEnumerable<ArticlePage> ListOfArticles { get; set; }
public ArticlePage CurrentPage { get; set; }
}
Add the following code to your page controller to load all child pages below the current page and create a view model.
public class ArticlePageController : PageController<ArticlePage>
{
private readonly IContentLoader _contentLoader;
public ArticlePageController(IContentLoader contentLoader)
{
_contentLoader = contentLoader;
}
public ActionResult Index(ArticlePage currentPage)
{
// Load all article pages that are direct children to the current page.
var pages = _contentLoader.GetChildren<ArticlePage>(currentPage.ContentLink);
// TODO: Add filter to hide unpublished pages and apply access control.
var model = new ArticlePageViewModel
{
CurrentPage = currentPage,
ListOfArticles = pages
};
return View(model);
}
}
Both GetPage and GetChildren expect an ID parameter implemented by the ContentReference class. You can find the current page ID in the property ContentLink, and the property ParentLink contains the ID of the parent page. The reason we have a class for the ID and not just a simple integer is to be able to load previosly published versions and drafts through the same methods.
You can filter the type of content you retrieved with GetChildren by using a different class on the generic method. So, PageData instead of ArticlePage in the call above gives you a list of any type of page, and IContent retrieves any type of content, including media, folders, and blocks.
Next step is to use the view model in your view to render the list of articles.
@using EPiServer.Core
@using EpiDemo.Models.Pages
@model EpiDemo.Models.ViewModels.ArticlePageViewModel
<!DOCTYPE html>
<html>
<head>
<title>@Model.CurrentPage.Name</title>
</head>
<body>
<h1>@Html.PropertyFor(m => m.CurrentPage.Name, new { customTag = "span" })</h1>
@Html.PropertyFor(m => m.CurrentPage.MainBody)
<h2>List of Articles</h2>
@foreach (ArticlePage item in Model.ListOfArticles)
{
<h3>@Html.ContentLink(item) <small>by @item.CreatedBy</small></h3>
<p>@item.TeaserText</p>
}
</body>
</html>
You can, of course, load other pages than the children of the current page. Here is an example that lets an editor pick the parent page for the list of pages by adding a property to our page type.
public class ArticlePage : PageData
{
...
public virtual ContentReference ParentForList { get; set; }
...
Then use this property as ID when you load the list of pages in your controller.
public ActionResult Index(ArticlePage currentPage)
{
// Load all article pages that are direct children to the page specified by
// the ParentForList property.
var pages = _contentLoader.GetChildren<ArticlePage>(currentPage.ParentForList);
...
Pages retrieved using calls to, for example, GetChildren are not automatically filtered for access and availability.
Important! As a developer you are always responsible to add funtionality for filtering content you have retrieved through Optimizely's API before you use it in a view. Forgetting this can cause broken links and you could also reveal sensitive information to unauthorized visitors of your site.
There are several utility classes to help you filter content. The first class you must learn how to use is FilterContentForVisitor. It uses three other filters to remove unpublished pages (FilterPublished), pages without templates (FilterTemplate), and pages to which the current visitor does not have access (FilterAccess).
public ActionResult Index(ArticlePage currentPage)
{
// Create filter to remove unpublished pages and apply access control.
var filter = new FilterContentForVisitor();
// Load all article pages that are direct children to the current page and apply filter.
var pages = _contentLoader.GetChildren<ArticlePage>(currentPage.ContentLink)
.Where(page => !filter.ShouldFilter(page));
var model = new ArticlePageViewModel
{
CurrentPage = currentPage,
ListOfArticles = pages
};
return View(model);
}
You will also see the static class FilterForVisitor in many examples. This legacy class uses FilterContentForVisitor but since it is static it makes unit testing harder to perform.
To learn more, see Search and filtering.
Top and left-navigation menus are common examples of site navigation components. To make the page list useful for navigation purposes, we must make it available from all pages on the site since navigation usually is rendered from shared layout templates.
One way to solve this is to let your controller prepare your view model with data needed by the shared layout, like meta data, menu items, breadcrumbs, and footer links. Another approach is to use helper methods directly in your shared views to retrieve what you need.
The following example shows an HtmlHelper extension method named MenuList that takes two parameters, first the ID of the parent page and the second is a html template. It gets all children of the parent page that should be visible for the visitor and renders the template. Selected is true if the Menu Item is in the path to the current page and that is often used to highlight where you are in the structure.
public static class HtmlHelperExtensions
{
public static IHtmlContent MenuList(
this IHtmlHelper helper,
ContentReference rootLink,
Func<MenuItem, HelperResult> itemTemplate)
{
var currentContentLink = helper.ViewContext.HttpContext.GetContentLink();
var contentLoader = helper.ViewContext.HttpContext.RequestServices.GetService<IContentLoader>();
var filterForVisitor = new FilterContentForVisitor();
var pagePath = contentLoader.GetAncestors(currentContentLink)
.Reverse()
.Select(x => x.ContentLink)
.SkipWhile(x => !x.CompareToIgnoreWorkID(rootLink))
.ToList();
var menuItems = contentLoader.GetChildren<PageData>(rootLink)
.Where(page => !filterForVisitor.ShouldFilter(page) && page.VisibleInMenu)
.Select(page => new MenuItem
{
Page = page,
Selected = page.ContentLink.CompareToIgnoreWorkID(currentContentLink) ||
pagePath.Contains(page.ContentLink)
})
.ToList();
var buffer = new StringBuilder();
var writer = new StringWriter(buffer);
foreach (var menuItem in menuItems)
{
itemTemplate(menuItem).WriteTo(writer, HtmlEncoder.Default);
}
return new HtmlString(buffer.ToString());
}
public class MenuItem
{
public PageData Page { get; set; }
public bool Selected { get; set; }
}
}
Use the MenuList extension method in your page layout to render a top menu with the start page as first item followed by all children directly below the start page.
@{
HelperResult TopMenuTemplate(HtmlHelperExtensions.MenuItem menuItem)
{
<li>
@if (menuItem.Selected)
{
<strong>@Html.ContentLink(menuItem.Page)</strong>
}
else
{
@Html.ContentLink(menuItem.Page)
}
</li>
return new HelperResult(w => Task.CompletedTask);
}
}
<nav>
<ul>
<li>
@Html.ContentLink(ContentReference.StartPage)
</li>
@Html.MenuList(ContentReference.StartPage, TopMenuTemplate)
</ul>
</nav>
You can also use MenuList to render hierarchical navigation by calling it recursively in your template.
@{
HelperResult SubMenuTemplate(HtmlHelperExtensions.MenuItem menuItem)
{
<li>
@if (menuItem.Selected)
{
<strong>@Html.ContentLink(menuItem.Page)</strong>
<ul>
@Html.MenuList(menuItem.Page.ContentLink, SubMenuTemplate)
</ul>
}
else
{
@Html.ContentLink(menuItem.Page)
}
</li>
return new HelperResult(w => Task.CompletedTask);
}
}
<nav>
<ul>
@Html.MenuList(ContentReference.StartPage, SubMenuTemplate)
</ul>
</nav>
To learn more, have a look at how the Alloy demo templates have been built.
A breadcrumb with the path to the current page can easily be created by using GetAnscestors that returns a list of all parents from the specified page up to the root page.
Here is an example of a partial template that renders breadcrumbs. It applies several filters to get rid of unwanted items.
@using EPiServer
@using EPiServer.Core
@using EPiServer.Filters
@using EPiServer.Web.Routing
@inject IContentLoader loader
@inject IContentRouteHelper contentRouteHelper
@{
var currentPage = contentRouteHelper.Content;
var filter = new FilterContentForVisitor();
var breadcrumbs = loader.GetAncestors(currentPage.ContentLink)
.OfType<PageData>()
.Reverse()
.SkipWhile(page => page.ContentLink != ContentReference.StartPage)
.Where(page => !filter.ShouldFilter(page) && page.VisibleInMenu)
.ToList();
}
<div>
@foreach (var page in breadcrumbs)
{
@Html.ContentLink(page) <span>/</span>
}
<strong>@currentPage.Name</strong>
</div>
You can also retrieve content through searching based on a set of criteria, or through query-based searches. You can use Optimizely Search & Navigation to add more powerful search features. Search & Navigation is automatically included if you are running the Digital Experience Platform (DXP).
Last updated: Jul 02, 2021