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:
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
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:
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
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.
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
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.
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
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.
Failed to load widget object.
The file '/CMSWebParts/Custom/HighlightJS/HighlightJS.ascx' does not exist.
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.