Martin Ryttler
Feb 1, 2018
  4026
(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?

Please login to comment.
Latest blogs
Fixing “Published By” After OKTA SSO in Optimizely CMS

Enabling Okta SSO significantly strengthened our authentication and security model in Optimizely CMS . However, like many real-world implementation...

Sanjay Kumar | Apr 1, 2026

OptiPowerTools.Hangfire: A Drop-in Hangfire Integration for Optimizely CMS 12

Back in 2024, I wrote a post on Adding Hangfire to Episerver/Optimizely CMS 12, walking through each step of integrating Hangfire into an Optimizel...

Stanisław Szołkowski | Mar 31, 2026 |

Upgrade Guide: Commerce 14 to Commerce 15 preview

This document provides step-by-step instructions to upgrade a Commerce site from Commerce 14/CMS 12 to Commerce 15/CMS 13 preview.   Overview   Thi...

Viet Anh Nguyen | Mar 31, 2026

Prove what your experimentation program is worth with Holdouts

Running experiments is one thing. Knowing whether your experimentation program is moving the business forward?  That's  the harder question.   Most...

Sarah Ager | Mar 30, 2026