CONTENTSTART
EXCLUDESTART EXCLUDEEND

Enabling Feature Folders / Custom View Paths in Xperience .Net Core

TLDR: To enable, create and modify the CustomLocationExpander class, then add it to the RazorViewEngineOptions in your startup.cs

Taking Control Of your Structure

Most developers who have worked in MVC are probably used to the normal structure of projects.  Controllers go in the Controllers folder, Models in the Models folder, Views in the Views folder.  And most developers who have worked in MVC long enough, also are probably used to the annoyance of having to hunt through the Solution Explorer to find all the corresponding models for the controller, the views you are working on, and scrolling up and down on large projects.  Visual Studio Shortcuts can help such as (Ctrl+M, Ctrl+G) on the View to go to the view file, f12 or ctrl+f12 on a model/interface, etc., but even with those when you want to create a new model, view, or supporting entity you often are stuck going through folder after folder.

There have been discussions for many years about better folder structures.  Two big keywords are Areas and Feature Folders.  These both have a similar feel and philosophy:  Keep Controllers, Models (and ideally Views) all in one folder.  You maybe also include other files and resources in these areas as well.  The goal then is when you want to work on some area or feature of your program, it is all in one spot.  You want to export that logic to an RCL, or share it with another person?  Everything is in one folder, making it very easy. 

So, what's the hold up?

Here is the rub: MVC has some default magic baked into it to determine what View your controller's Action/View Component uses.  This means that if you want to avoid having to define the View path manually (which you can do…), we need to do some work to expand this.  Let us look under the hood at how MVC, and Kentico, works with default View Paths.

Know your MVC Defaults

Both in MVC and MVC .Net Core, there are default View Path formats.  Whenever a Controller's Action returns a View, it sends some properties to these formats.  First Let us look at the Properties:

{0} – View Name
{1} – Controller Name
{2} – Area Name (if using Areas)

The Default View Paths for these Controller Actions are as follows:

~/Views/{1}/{0}.cshtml
~/Views/Shared/{0}.cshtml

Let us take a look at the below as an example:

public class SomeTestController : Controller {
public ActionResult MyAction()
        {
            return View();
        }
}
// Controller Name = "SomeTest"
// View Name = "MyAction"

So the default View paths will be

  • /Views/SomeTest/MyAction.cshtml
  • /Views/Shared/MyAction.cshtml

In .Net Core, we also have View Components, there is no Controller but instead the ViewComponent Name determines the default View Name in the format of Component/[ComponentName]/Default

Again let's look at the below example:

[ViewComponent(Name = "MyTool")]
public class MyToolViewComponent: ViewComponent {
     public IViewComponentResult Invoke()
        {
            return View();
        }
}
// View Name = “Component/MyTool/Default”
// No Controller Name

So the default View paths are

  • /Views/Components/MyTool/Default.cshtml
  • /Views/Shared/Components/MyTool/Default.cshtml

Know your Xperience Basic Implementation Defaults

Xperience has a couple of entities that have basic implementations (that do not require you create a controllers or ViewComponent).  Since there is no Controller or ViewComponent (well there is, but it is Xperience's code), the View path is generated and specified by Xperience itself.  Documentation mentions these, but let us look at them:

Page Types

When using Xperience's Page Builder system, if you have a page type that has Page Builder enabled, when visiting a page will route you to the appropriate rendering.  If you go with the default Basic Routing, Xperience will generate the following View Name:
  • PageTypes/{PageTypeClassNameWithPeriodReplacedWithUnderscore}
So, if your Page type is Custom.MyPageType, it would render a view name of PageTypes/Custom_MyPageType which will look for the view file in
  • /Views/Shared/PageTypes/Custom_MyPageType.cshtml

And will pass the Kentico.Content.Web.Mvc.Routing.IPageViewModel<TreeNode> model to it (you can use either TreeNode, or your actual Page Type's Model). 

This View Path is generated through a hard-coded string in one of the internal classes.

Page Templates, Widgets, Sections

With each of these entities, you also have the option to do either a basic or an advanced implementation.  All 3 of these use the same internal static class ComponentViewUtils to find the View for asic Implementations.  This helper receives 3 fields (identifier, defaultFolderName, customViewName) and generates the View in this format:
return string.IsNullOrEmpty(customViewName) ? $ "{defaultFolderName}/_{identifier}" : customViewName;

Identifier is the code name of the widget, section, or page template, with the periods replaced with underscores.
DefaultFolderName is one of the following: [Sections, Widgets, PageTemplates]
CustomViewName is an optional parameter when you register your PageTemplate, Widget, or Section in the Basic configuration. 

Here are some examples:

  • Widget My.Widget
    • Generate view name: Widgets/_My_Widget
    • MVC will search (by default) Views/Widgets/_My_Widget.cshtml or Views/Shared/Widgets/_My_Widget.cshtml
  • Section Some.Section
    • Generated view name: Sections/_Some_Section
    • MVC will search (by default) Views/Sections/_Some_Section.cshtml or Views/Shared/Sections/_Some_Section.cshtml
  • Page Template My.Template
    • Generated view name: PageTemplates/_My_Template
    • MVC will search (by default) Views/PageTemplates/_My_Template.cshtml or Views/Shared/Sections/_My_Template.cshtml
These generated paths can always be overwritten by passing a CustomViewName when registering the basic implementation attributes for these elements. 

How to Take control

The first step to this is expanding the default View Paths.  This will allow you to expand how Views are determined.  For .Net Core you will want to implement your own IViewLocationExpander and configuring that for your RazorViewEngineOption's ViewLocationExpanders.  This will allow you to add onto the default View Path Formats.


// Startup.cs
public void ConfigureServices(IServiceCollection services)
       	{
	    ...
            services.Configure<RazorViewEngineOptions>(options =>
            {
                options.ViewLocationExpanders.Add(new CustomLocationExpander());
            });
        }

 

// Complete code is further down, do NOT use this yet

using Microsoft.AspNetCore.Mvc.Razor;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public class CustomLocationExpander : IViewLocationExpander
{


    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        /* Parameters:
            * {2} - Area Name
            * {1} - Controller Name
            * {0} - View Name
            */
        List<string> Paths = new List<string> { 
            // Default View Locations to support imported / legacy paths
            "/Views/{1}/{0}.cshtml",
            "/Views/Shared/{0}.cshtml",

            // Adds Feature Folder Rendering
            "/Features/{1}/{0}.cshtml",
            "/Features/Shared/{0}.cshtml",
        }
        return Paths;
    }
}
 

As you can see in my custom implementation, so far I've only added /Features/{1}/{0}.cshtml and /Features/Shared/{0}.cshtml to the list.  This is the typical Feature Folder logic, so now the View can exist within the same folder as the feature in the lookup.

However, this still does not resolve the issues with Xperience and ViewComponents having rather hard-set View Names that get passed to these View Paths as the {0} parameter.  To make matters worse, you cannot modify the context.ViewName, context.ControllerName, or really any of the context.  The only thing you can modify is the context.Values (a dictionary) and the list of paths you return.

There are ways to modify the default rendering logic for ViewComponents, but it involves custom ViewResults implementations, and there is no way currently to modify Xperience's default View Name generation.  So, what can be done?

Render Your Own Path!

Your ExpanderViewLocations returns a list of Paths that then are used with the context to build the list of Paths.  It dawned on me, what is to prevent me from just returning a completely custom path? One I Generate, that does not use the {0} or {1} parameters at all.  And sure enough, it works! 

This was literally where I started doing a happy dance.

So what you can do, is during the IViewLocationExpander.PopulateValues method, you can analyze the context and add a custom View Name / Controller Name to the context.Values dictionary.  Then on the IViewLocationExpander.ExpanderViewLocations you can do a check if those custom values exist, and along with the defaults, render out a list of already formatted paths using your custom values.  To make your life easier, I have created this expander that already has the logic set in place, with regex checkers that group the elements of the ViewPaths to make it easier for you to construct your own.

using Microsoft.AspNetCore.Mvc.Razor;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;


    public class CustomLocationExpander : IViewLocationExpander
    {
        private const string _CustomViewPath = "CustomViewPath";
        private const string _CustomController = "CustomController";
        public void PopulateValues(ViewLocationExpanderContext context)
        {
            Regex DefaultKenticoViewDetector = new Regex(@"^((?:[Ww]idgets|[Ss]ections|[Pp]age[Tt]emplates))+\/_+((.+)+_+(.+))");
            Regex DefaultKenticoPageTypeDetector = new Regex(@"^((?:[Pp]age[Tt]ypes))+\/+((.+)+_+(.+))");
            Regex DefaultComponentDetector = new Regex(@"^((?:[Cc]omponents))+\/+([\w\.]+)\/+(.*)");

            /* If successful
             * Group 0 = FullMatch (ex "Widgets/_My_CustomWidget")
             * Group 1 = Widgets/Sections/PageTemplates/PageTypes (ex "Widgets")
             * Group 2 = The Code Name (ex "My_CustomWidget")
             * Group 3 = Namespace (ex "My")
             * Group 4 = Code (ex "CustomWidget")
             * ex 0 =, 1 = , 2 = , 3 = , 4 = 
             * */
            var DefaultKenticoViewsMatch = DefaultKenticoViewDetector.Match(context.ViewName);
            var DefaultKenticoPageTypeMatch = DefaultKenticoPageTypeDetector.Match(context.ViewName);

            /*
             * If successful, 
             * Group 0 = FullMatch (ex "Components/MyComponent/Default")
             * Group 1 = Components (ex "Component")
             * Group 2 = Component Name (ex "MyComponent")
             * Group 3 = View Name (ex "Default")
             * */
            var DefaultComponentMatch = DefaultComponentDetector.Match(context.ViewName);

            if (DefaultKenticoViewsMatch.Success)
            {
                // I'm going to store Widgets, Sections, and Page Templates as the Widgets|Sections|PageTemplates/WidgetCodeName/Default
                context.Values.Add(_CustomViewPath, string.Format("{0}/{1}/Default", DefaultKenticoViewsMatch.Groups[1].Value, DefaultKenticoViewsMatch.Groups[2].Value.Replace("_", "")));
                context.Values.Add(_CustomController, DefaultKenticoViewsMatch.Groups[1].Value);
            } else if (DefaultKenticoPageTypeMatch.Success) {
                context.Values.Add(_CustomViewPath, string.Format("{0}/{1}/Default", DefaultKenticoPageTypeMatch.Groups[1].Value, DefaultKenticoPageTypeMatch.Groups[2].Value.Replace("_", "")));
                context.Values.Add(_CustomController, DefaultKenticoPageTypeMatch.Groups[1].Value);
            } else if (DefaultComponentMatch.Success)
            {
                // Stripping "Component" out so widget, section, page template View components can go under the main root
                context.Values.Add(_CustomViewPath, string.Format("{0}/{1}", DefaultComponentMatch.Groups[2].Value, DefaultComponentMatch.Groups[3].Value));
                context.Values.Add(_CustomController, context.ControllerName);
            }

        }

        public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
        {
            /* Parameters:
             * {2} - Area Name
             * {1} - Controller Name
             * {0} - View Name
             */
            List<string> Paths = new List<string> { 
                // Default View Locations to support imported / legacy paths
                "/Views/{1}/{0}.cshtml",
                "/Views/Shared/{0}.cshtml",

                // Adds Feature Folder Rendering
                "/Features/{1}/{0}.cshtml",
                "/Features/Shared/{0}.cshtml",

                // Paths for my Custom Structure, leveraged with the _CustomViewPath and _CustomController values set in PopulateValues
                // Handles Basic Widgets/Sections/PageTemplates
                "/{0}.cshtml", 
                // Adds /Components/{ComponentName}/{ComponentViewName}.cshtml
                "/Components/{0}.cshtml",
                // Adds /Widgets/{WidgetComponentName}/{ComponentViewName}.cshtml
                "/Widgets/{0}.cshtml", 
                // Adds /Sections/{SectionComponentName}/{ComponentViewName}.cshtml
                "/Sections/{0}.cshtml", 
                // Adds /PageTemplates/{PageTemplateComponentName}/{ComponentViewName}.cshtml
                "/PageTemplates/{0}.cshtml", 
                };

            // Add "Hard Coded" custom view paths to checks, along with the normal default view paths for backward compatability
            if (context.Values.ContainsKey(_CustomViewPath))
            {
                var CombinedPaths = new List<string>(Paths.Select(x => string.Format(x, context.Values[_CustomViewPath], context.Values[_CustomController], "")));
                CombinedPaths.AddRange(Paths);
                return CombinedPaths;
            }
            
            // Returns the normal view paths
            return Paths;
        }
    }

Exporting / Sharing

It is important to note that if you do your own View logic, it is central to your project.  If you ever want to share something in a Razor Control Library, or share widgets and the like, it is very important that you hard code your view paths.  This eliminates any conflict or dependency on your custom view rendering.  I also recommend that you use a public string constant in the class that contains the path name.  This means if someone wants to overwrite your View, they have the exact path that they need to put their view in.

[ViewComponent(Name = "MyCustom")]
public class MyCustomViewComponent : ViewComponent
{
    public const string _ViewPath = "~Views/Components/MyCustom/Default.cshtml";
    public IViewComponentResult Invoke()
    {
        return View();
    }
}

Performance Questions

One good question is what performance this has on your application.  This ExpanderView is called whenever the View is not a fully valid path, so this logic will run with each return View().  However, our logic consists of 3 regex checks (which are nothing in terms of performance time), a loop through the Views array to render the custom views (again, virtually nothing in terms of processing time), and MVC creates a list of all the current Views by Path at the beginning of the application, so the only final impact is possibly double the amount of string matches to make against that view dictionary, again which should not make any impact.  I would say this customization should not impact performance at all.

Conclusion

Looks like we have threaded the needle on this one.  Enabling Feature folders and customizing the view rendering is simple with the trick I created and is fully backwards compatible.  Hopefully, this will better optimize your project structure as it has mine, and thus helped you build sites with less frustration and time.
 
Comments
Blog post currently doesn't have any comments.
Is three < than three? (true/false)
CONTENTEND