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