Martin Ryttler
Feb 1, 2018
  2512
(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
Customizing Property Lists in Optimizely CMS

Generic property lists is a cool editorial feature that has gained a lot of popularity - in spite of still being unsupported (officially). But if y...

Allan Thraen | Oct 2, 2022 | Syndicated blog

Content Delivery API – The Case of the Duplicate API Refresh Token

Creating a custom refresh provider to resolve the issues with duplicate tokens in the DXC The post Content Delivery API – The Case of the Duplicate...

David Lewis | Sep 29, 2022 | Syndicated blog

New Optimizely certifications - register for beta testing before November 1st

In January 2023, Optimizely is making updates to the current versions of our certification exams to make sure that each exam covers the necessary...

Jamilia Buzurukova | Sep 28, 2022

Optimizely community meetup - Sept 29 (virtual + Melbourne)

Super excited to be presenting this Thursday the 29th of September at the Optimizely community meetup. For the full details and RSVP's see the...

Ynze | Sep 27, 2022 | Syndicated blog