Martin Ryttler
Feb 1, 2018
visibility 4289
star star star star star star
(4 votes)

Custom view engine to organize views

Our typical Episerver projects normally includes about ten to twenty page types and about the same amount of block types. Many of these are pages with multiple views. And to keep the project manageable we needed a way to organize these into an easy folder structure. Our approach was to build a custom RazorViewEngine that fitted our Episerver project patterns and that was to group all views for a page type under a folder with the same name as the Model and place them under /Views/Pages. And the same for the block types. Something like this

  • Views
    • Pages
      • StartPage
        • Index.cshtml
        • ...
      • MyProfilePage
        • Edit.cshtml
        • Error.cshtml
        • Index.cshtml
        • Updated.cshtml
        • ...
      • ...
    •  Blocks
      • LoginBlock
        • Error.cshtml
        • Index.cshml
        • ...
      • ...

There are other examples out there of how to handle this but we’ve added a few tweaks of our own to make this fit Episerver projects that makes it worth mentioning:

  • First of all we wanted to assure that this didn’t interfere with any other view handling
  • We wanted to use the name of the Model instead of the Controller since we're really trying to limit our controllers and instead use generic controllers
  • It also needed to work with our pattern using a IPageViewModel (known from Episerver Alloy templates) but we didn’t want to require the use of a IPageViewModel

This is what we ended up with.

namespace Metamatrix.Web.Episerver.Base.Business.Initialization
{
    using System;
    using System.Linq;
    using System.Web.Mvc;
    using EPiServer;
    using Models.Blocks.Base;
    using Models.Pages.Base;
    using Models.Settings;
    using Models.ViewModels;

    /// <summary>
    ///     An RazorViewEngine that's adapted to find view on a path based on the EPi GetOriginalType of the model instead of
    ///     the name of the controller.
    ///     This makes it possible to create more generic controllers
    /// </summary>
    public class ExtendedRazorViewEngine : RazorViewEngine
    {
        /// <summary>
        ///     Finds the specified partial view by using the specified controller context.
        /// </summary>
        /// <param name="controllerContext">The controller context.</param>
        /// <param name="partialViewName">The name of the partial view.</param>
        /// <param name="useCache">true to use the cached partial view.</param>
        /// <returns>
        ///     The partial view.
        /// </returns>
        /// <exception cref="System.ArgumentNullException">When the controllerContext parameter is null</exception>
        /// <exception cref="System.ArgumentException">When partialViewName parameter is null or empty.</exception>
        public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName,
            bool useCache)
        {
            if (controllerContext == null)
                throw new ArgumentNullException(nameof(controllerContext), "The controllerContext parameter is null");

            if (string.IsNullOrEmpty(partialViewName))
                throw new ArgumentException("The viewName parameter is null or empty.", nameof(partialViewName));

            if (controllerContext.Controller != null)
            {
                var modelName = GetModelname(controllerContext.Controller.ViewData.Model);
                if (!string.IsNullOrEmpty(modelName))
                {
                    var cacheKey = $"{modelName}|{partialViewName}";

                    if (useCache && ViewLocationCache != null)
                    {
                        var cachedLocation = ViewLocationCache.GetViewLocation(controllerContext.HttpContext, cacheKey);
                        if (!string.IsNullOrEmpty(cachedLocation))
                            return new ViewEngineResult(CreatePartialView(controllerContext, cachedLocation), this);
                    }

                    string trimmedViewName;
                    if (partialViewName.EndsWith(".cshtml") || partialViewName.EndsWith(".vbhtml"))
                        trimmedViewName = partialViewName.Remove(partialViewName.Length - 7);
                    else
                        trimmedViewName = partialViewName;
                    var args = new object[] {trimmedViewName, modelName};

                    foreach (var location in PartialViewLocationFormats)
                    {
                        var path = string.Format(location, args);
                        if (FileExists(controllerContext, path))
                        {
                            ViewLocationCache?.InsertViewLocation(controllerContext.HttpContext, cacheKey, path);
                            return new ViewEngineResult(CreatePartialView(controllerContext, path), this);
                        }
                    }
                    return new ViewEngineResult(PartialViewLocationFormats.Select(i => string.Format(i, args)));
                }
            }
            return base.FindPartialView(controllerContext, partialViewName, useCache);
        }


        /// <summary>
        ///     Finds the specified view by using the specified controller context and master view name.
        /// </summary>
        /// <param name="controllerContext">The controller context.</param>
        /// <param name="viewName">The name of the view.</param>
        /// <param name="masterName">The name of the master view.</param>
        /// <param name="useCache">true to use the cached view.</param>
        /// <returns>
        ///     The page view.
        /// </returns>
        /// <exception cref="System.ArgumentNullException">When controllerContext parameter is null</exception>
        /// <exception cref="System.ArgumentException">When the viewName parameter is null or empty.</exception>
        public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName,
            string masterName, bool useCache)
        {
            if (controllerContext == null)
                throw new ArgumentNullException(nameof(controllerContext), "The controllerContext parameter is null");

            if (string.IsNullOrEmpty(viewName))
                throw new ArgumentException("The viewName parameter is null or empty.", nameof(viewName));

            if (controllerContext.Controller != null)
            {
                var modelName = GetModelname(controllerContext.Controller.ViewData.Model);
                if (!string.IsNullOrEmpty(modelName))
                {
                    var cacheKey = $"{modelName}|{viewName}";

                    if (useCache && ViewLocationCache != null)
                    {
                        var cachedLocation = ViewLocationCache.GetViewLocation(controllerContext.HttpContext, cacheKey);
                        if (!string.IsNullOrEmpty(cachedLocation))
                            return new ViewEngineResult(CreateView(controllerContext, cachedLocation, masterName),
                                this);
                    }

                    string trimmedViewName;
                    if (viewName.EndsWith(".cshtml") || viewName.EndsWith(".vbhtml"))
                        trimmedViewName = viewName.Remove(viewName.Length - 7);
                    else
                        trimmedViewName = viewName;
                    var args = new object[] {trimmedViewName, modelName};

                    foreach (var location in ViewLocationFormats)
                    {
                        var path = string.Format(location, args);
                        if (FileExists(controllerContext, path))
                        {
                            ViewLocationCache?.InsertViewLocation(controllerContext.HttpContext, cacheKey, path);
                            return new ViewEngineResult(CreateView(controllerContext, path, masterName), this);
                        }
                    }
                    return new ViewEngineResult(ViewLocationFormats.Select(i => string.Format(i, args)));
                }
            }
            return base.FindView(controllerContext, viewName, masterName, useCache);
        }

        private string GetModelname(object model)
        {
            if (model is BaseBlock || model is BasePage)
                return model.GetOriginalType().Name;

            var viewModel = model as IPageViewModel<BasePage>;
            return viewModel?.CurrentPage.GetOriginalType().Name ?? "";
        }
    }
}

It’s actually the method GetModelName that fulfills the three requirements above. We’re checking if the model inherits from either our BaseBlock or BasePage, or if it’s an IPageViewModel<BasePage>. In our solutions BaseBlock and BasePage are the base classes for all our blocks and pages. Slim base classes that inherits from Episervers BlockData and PageData. We also use the GetOriginalType extension to make sure that we get our Type and not the generated proxy.

I hope all of you’re using some kind of base classes for your blocks and pages (if not you should consider it) but otherwise it should be easy to adapt the GetModelName method to fit your needs.

We put this to use by registering a new ViewEngine in the Application_Start event and here we specify the actual path patterns to use. The symbol {0} is replaced by the view name, and {1} is the name of the model.

	public class EPiServerApplication : Global
	{
		protected void Application_Start()
		{

			...

			RegisterViewLocations();

			...

		}

		private static void RegisterViewLocations()
		{
			ViewEngines.Engines.Add(new ExtendedRazorViewEngine
			{
				ViewLocationFormats = new[]
				{
					"~/Views/Pages/{1}/{0}.cshtml"
				},
				PartialViewLocationFormats = new[]
				{
					"~/Views/Shared/Partials/{0}.cshtml",
					"~/Views/Pages/{1}/{0}.cshtml",
					"~/Views/Blocks/{1}/{0}.cshtml"
				}
			});
		}

	}
}

That's wraps this up. I hope that it could be of some use or atleast give you guys some ideas to be creative.

Feb 01, 2018

Comments

valdis
valdis Feb 1, 2018 11:41 AM

why not feature folders?

error Please login to comment.
Latest blogs
Surviving a Dirty Feed. Part 3: Error Handling and Event Batching

Committing a 250,000-row Optimizely catalog import row by row, returning a per-row failure report, and coalescing catalog events into one per batch.

Stanisław Szołkowski | Oct 16, 2026 |

Building UI Extensions for Optimizely SaaS CMS

Introduction Developers have been extending the functionality of Optimizely PaaS CMS for years using addons built in C# and deployed as NuGet...

Mark Stott | Oct 15, 2026

Cheapskate AI Translator: Because Translation Should Not Require a Second Mortgage

There is a particular kind of software project that begins with a detailed architecture document, a product roadmap, and a carefully assembled...

Linh Doan Cuu | Oct 15, 2026

Computed Fields in CMS 13 Content Contracts with Optimizely Graph

Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph. But what if the...

Mardy | Oct 14, 2026