Enabling Feature Folders in Xperience 13 MVC.Net Core

   —   

Many developers love MVC, but hate having all the peices of their code scattered throughout their solution.  Feature Folders allow you to store your Models, Views, and Controllers (or ViewComponents) in a single folder, keeping your structure organized and neat.  This article provides the code to take full control over your structure in general, and View Paths in specific.

Code First, Explanation Second

In order to customize how .Net Core MVC finds Views by default, you need to add a custom IViewLocationExpander and add it to the RazorViewEngineOption's ViewLocationExpander in your Startup.cs -> ConfigureServices

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

Here's a boilerplate CustomLocationExpander that you can customize.  The basic premise is this allows you to detect Kentico Xperience entities (Sections, Widgets, Page Templates, Page Types) or default ViewComponents and then create your own View Name / Controller Name to use in your ExpandedViewLocations ViewPath generations.  This code is backwards compatible with default View Paths.

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") * */ 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. // If Component View Names is NOT customized in PopulateValues, this would enable views in Components/ComponentName/Default.cshtml "/{0}.cshtml", // Adds /Components/{ComponentName}/{ComponentViewName}.cshtml // Added in because I did customize Component View Names in PopulateValues by removing the "Components/" from the beginning "/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; } }


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+MCtrl+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

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 The CustomLocationExpander Works

As you saw in the code at the beginning of the article, we added Feature Folder View Paths, along with Component Handling (Normally the path for Components is Components/ComponentName/Default, which without the /{0}.cshtml it will only check the Views/Components/ComponentName/Default.cshtml or Views/Shared/Components/ComponentName/Default.cshtml):

// 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", // Handles Basic Widgets/Sections/PageTemplates. // If Component View Names is not customized in PopulateValues, this would enable views in Components/ComponentName/Default.cshtml "/{0}.cshtml", // Adds /Components/{ComponentName}/{ComponentViewName}.cshtml // Added in because I did customize Component View Names in PopulateValues by removing the "Components/" from the beginning "/Components/{0}.cshtml",

The real magic though occurs in the PopulateValues method.  This will check the incoming View Path in the context, and through the use of Regex expressions, detect Kentico Defaults / ViewComponent paths and split them up.  You can then render out your own View Name and Controller Name and pass it to the context.Values.  In my case, I've made Widgets/Sections/PageTypes have a View Name of [Type]/[CodeName]/[Default] always, and for View Components I removed the preceding Components/ so i can find Widget/Section/PageTemplate/PageType View Components in separate top level folders.

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\.]+)\/+(.*)"); var DefaultKenticoViewsMatch = DefaultKenticoViewDetector.Match(context.ViewName); var DefaultKenticoPageTypeMatch = DefaultKenticoPageTypeDetector.Match(context.ViewName); var DefaultComponentMatch = DefaultComponentDetector.Match(context.ViewName); if (DefaultKenticoViewsMatch.Success) { 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); } }

The last step is in this area of the ExpandViewLocations method where it detects that you have added a custom View name/Controller Name, it will use these to generate your View Paths (ex /Widgets/MyWidget/Default.cshtml), then add the default paths after for backward compatibility:

// 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;

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.

 

Share this article on   LinkedIn

Trevor Fayas

Fast, Efficient, and coded well, Kentico has been my platform of choice for years. I am a Software Engineer with Heartland Business Systems, with dozens of Kentico sites under my belt, both small quick sites, ecommerce sites, School / College Sites, and global corporate sites. I've also crafted many Webparts and Form Tools in the Marketplace. Ask me anything!