How to specify the view location in asp.net core mvc when using custom locations?
Asked Answered
E

10

56

Let's say I have a controller that uses attribute based routing to handle a requested url of /admin/product like so:

[Route("admin/[controller]")]        
public class ProductController: Controller {

    // GET: /admin/product
    [Route("")]
    public IActionResult Index() {

        return View();
    }
}

Now let's say that I'd like to keep my views organized in a folder structure that roughly reflects the url paths they are related to. So I'd like the view for this controller to be located here:

/Views/Admin/Product.cshtml

To go further, if I had a controller like this:

[Route("admin/marketing/[controller]")]        
public class PromoCodeListController: Controller {

    // GET: /admin/marketing/promocodelist
    [Route("")]
    public IActionResult Index() {

        return View();
    }
}

I would like the framework to automatically look for it's view here:

Views/Admin/Marketing/PromoCodeList.cshtml

Ideally the approach for informing the framework of the view location would work in a general fashion based on the attribute based route information regardless of how many url segments are involved (ie. how deeply nested it is).

How can I instruct the the Core MVC framework (I'm currently using RC1) to look for the controller's view in such a location?

Erastes answered 20/4, 2016 at 14:40 Comment(0)
E
53

You can expand the locations where the view engine looks for views by implementing a view location expander. Here is some sample code to demonstrate the approach:

public class ViewLocationExpander: IViewLocationExpander {

    /// <summary>
    /// Used to specify the locations that the view engine should search to 
    /// locate views.
    /// </summary>
    /// <param name="context"></param>
    /// <param name="viewLocations"></param>
    /// <returns></returns>
    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) {
        //{2} is area, {1} is controller,{0} is the action
        string[] locations = new string[] { "/Views/{2}/{1}/{0}.cshtml"};
        return locations.Union(viewLocations);          //Add mvc default locations after ours
    }


    public void PopulateValues(ViewLocationExpanderContext context) {
        context.Values["customviewlocation"] = nameof(ViewLocationExpander);
    }
}

Then in the ConfigureServices(IServiceCollection services) method in the startup.cs file add the following code to register it with the IoC container. Do this right after services.AddMvc();

services.Configure<RazorViewEngineOptions>(options => {
        options.ViewLocationExpanders.Add(new ViewLocationExpander());
    });

Now you have a way to add any custom directory structure you want to the list of places the view engine looks for views, and partial views. Just add it to the locations string[]. Also, you can place a _ViewImports.cshtml file in the same directory or any parent directory and it will be found and merged with your views located in this new directory structure.

Update:
One nice thing about this approach is that it provides more flexibility then the approach later introduced in ASP.NET Core 2 (Thanks @BrianMacKay for documenting the new approach). So for example this ViewLocationExpander approach allows for not only specifying a hierarchy of paths to search for views and areas but also for layouts and view components. Also you have access to the full ActionContext to determine what an appropriate route might be. This provides alot of flexibility and power. So for example if you wanted to determine the appropriate view location by evaluating the path of the current request, you can get access to the path of the current request via context.ActionContext.HttpContext.Request.Path.

Erastes answered 21/4, 2016 at 14:33 Comment(5)
This is a good solution however, this does not solve the problem of finding a view that has a route attribute on the action or controller. The View method still appears to use the name of the controller and not the route name to locate the view.Greenlee
@Xipooo, good point. The example I provided is a good start but to use the route you can set locations array to include /Views + context.ActionContext.HttpContext.Request.Path + either index.cshtml or .cshtml.Erastes
This allowed me to use a view that was added to the bin folder via a .net standard 1.6. Thank you great solution.Bibliographer
Could you explain why you are doing this context.Values["customviewlocation"] = nameof(ViewLocationExpander) in the PopulateValues method? Is this necessary or does it optimize performance or does it have any other purpose?Attached
I had the same question when initially working on this and it took a long time to get good answers. See here: #36803161 The short answer it that it provides additional data incorporated into the cache key used to for the location value.Erastes
H
98

Great news... In ASP.NET Core 2 and up, you don't need a custom ViewEngine or even ExpandViewLocations anymore.

Using the OdeToCode.AddFeatureFolders Package

This is the easiest way... K. Scott Allen has a nuget package for you at OdeToCode.AddFeatureFolders that is clean and includes optional support for areas. Github: https://github.com/OdeToCode/AddFeatureFolders

Install the package, and it's as simple as:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc()
                .AddFeatureFolders();

        ...
    }

    ...
}  

DIY

Use this if you need extremely fine control over your folder structure, or if you aren't allowed/don't want to take the dependency for whatever reason. This is also quite easy, although perhaps more cluttery than the nuget package above:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
         ...

         services.Configure<RazorViewEngineOptions>(o =>
         {
             // {2} is area, {1} is controller,{0} is the action    
             o.ViewLocationFormats.Clear(); 
             o.ViewLocationFormats.Add("/Controllers/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
             o.ViewLocationFormats.Add("/Controllers/Shared/Views/{0}" + RazorViewEngine.ViewExtension);

             // Untested. You could remove this if you don't care about areas.
             o.AreaViewLocationFormats.Clear();
             o.AreaViewLocationFormats.Add("/Areas/{2}/Controllers/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
             o.AreaViewLocationFormats.Add("/Areas/{2}/Controllers/Shared/Views/{0}" + RazorViewEngine.ViewExtension);
             o.AreaViewLocationFormats.Add("/Areas/Shared/Views/{0}" + RazorViewEngine.ViewExtension);
        });

        ...         
    }

...
}

And that's it! No special classes required.

Dealing with Resharper/Rider

Bonus tip: if you're using ReSharper, you might notice that in some places ReSharper can't find your views and gives you annoying warnings. To work around that, pull in the Resharper.Annotations package and in your startup.cs (or anywhere else really) add one of these attributes for each of your view locations:

[assembly: AspMvcViewLocationFormat("/Controllers/{1}/Views/{0}.cshtml")]
[assembly: AspMvcViewLocationFormat("/Controllers/Shared/Views/{0}.cshtml")]

[assembly: AspMvcViewLocationFormat("/Areas/{2}/Controllers/{1}/Views/{0}.cshtml")]
[assembly: AspMvcViewLocationFormat("/Controllers/Shared/Views/{0}.cshtml")]

Hopefully this spares some folks the hours of frustration I just lived through. :)

Highway answered 24/10, 2017 at 3:40 Comment(7)
You are my hero. I came to this question because I wanted to implement feature-based folder structure and you've already done it. Fantastic!Donndonna
@JohnHargrove Thanks for the kind words, you brightened a difficult day :)Highway
I love the Bonus tip about the ReSharper! I was highlighting red all my calls to View and PartialView.Attached
Dude. That is some great news.Sada
Can I use in the name of controller parent? I need to share view between two related controller (only these two) that extends the same controller.Ugo
@IvanMontilla If you want to share a view, you have to put it under Shared (/Controllers/Shared/Views in this example). It will then be available in all controllers unfortunately, but that's the only way I'm aware of. Other than using Areas, which would be wild overkill.Highway
For ReSharper, you may also need to use AspMvcPartialViewLocationFormat. Make sure you install the JetBrains.Annotations package.Casimir
E
53

You can expand the locations where the view engine looks for views by implementing a view location expander. Here is some sample code to demonstrate the approach:

public class ViewLocationExpander: IViewLocationExpander {

    /// <summary>
    /// Used to specify the locations that the view engine should search to 
    /// locate views.
    /// </summary>
    /// <param name="context"></param>
    /// <param name="viewLocations"></param>
    /// <returns></returns>
    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) {
        //{2} is area, {1} is controller,{0} is the action
        string[] locations = new string[] { "/Views/{2}/{1}/{0}.cshtml"};
        return locations.Union(viewLocations);          //Add mvc default locations after ours
    }


    public void PopulateValues(ViewLocationExpanderContext context) {
        context.Values["customviewlocation"] = nameof(ViewLocationExpander);
    }
}

Then in the ConfigureServices(IServiceCollection services) method in the startup.cs file add the following code to register it with the IoC container. Do this right after services.AddMvc();

services.Configure<RazorViewEngineOptions>(options => {
        options.ViewLocationExpanders.Add(new ViewLocationExpander());
    });

Now you have a way to add any custom directory structure you want to the list of places the view engine looks for views, and partial views. Just add it to the locations string[]. Also, you can place a _ViewImports.cshtml file in the same directory or any parent directory and it will be found and merged with your views located in this new directory structure.

Update:
One nice thing about this approach is that it provides more flexibility then the approach later introduced in ASP.NET Core 2 (Thanks @BrianMacKay for documenting the new approach). So for example this ViewLocationExpander approach allows for not only specifying a hierarchy of paths to search for views and areas but also for layouts and view components. Also you have access to the full ActionContext to determine what an appropriate route might be. This provides alot of flexibility and power. So for example if you wanted to determine the appropriate view location by evaluating the path of the current request, you can get access to the path of the current request via context.ActionContext.HttpContext.Request.Path.

Erastes answered 21/4, 2016 at 14:33 Comment(5)
This is a good solution however, this does not solve the problem of finding a view that has a route attribute on the action or controller. The View method still appears to use the name of the controller and not the route name to locate the view.Greenlee
@Xipooo, good point. The example I provided is a good start but to use the route you can set locations array to include /Views + context.ActionContext.HttpContext.Request.Path + either index.cshtml or .cshtml.Erastes
This allowed me to use a view that was added to the bin folder via a .net standard 1.6. Thank you great solution.Bibliographer
Could you explain why you are doing this context.Values["customviewlocation"] = nameof(ViewLocationExpander) in the PopulateValues method? Is this necessary or does it optimize performance or does it have any other purpose?Attached
I had the same question when initially working on this and it took a long time to get good answers. See here: #36803161 The short answer it that it provides additional data incorporated into the cache key used to for the location value.Erastes
C
36

In .net core you can specify the whole path to the view.

return View("~/Views/booking/checkout.cshtml", checkoutRequest);

Crus answered 13/1, 2017 at 21:16 Comment(2)
Totally true but I was looking for a solution for getting the framework to automatically find the view in a custom location without having to specify it this way. This is a good solution though for people who want to manually specify the view location.Erastes
Yep, I like your answer much more than the accepted one. I was about to use it until I realized it was overkill for what I needed. I posted this because I was able to resolve my problem without having to add any custom code. Maybe someone will find this handy. Thanks for your answer! RegardsCrus
A
9

I am using core 3.1 and just do this inside of ConfigureServices method inside of Startup.cs.

 services.AddControllersWithViews().AddRazorOptions(
     options => {// Add custom location to view search location
         options.ViewLocationFormats.Add("/Views/Shared/YourLocation/{0}.cshtml");                    
     });

the {0} is just a place holder for the view name. Nice and simple.

Absenteeism answered 6/12, 2019 at 19:9 Comment(2)
Once this code is implemented, how/where do you actually use it? Please can you provide an example? ThanksVolant
By default the _Shared folder is the default view location searched when looking for views by name when view is actually searched for(unless the full path is specifed) . When adding this to the configuration, it just adds the location specified to the search locations that is searched by default when a full path is not specified.Absenteeism
B
4

You're going to need a custom RazorviewEngine for this one.

First, the engine:

public class CustomEngine : RazorViewEngine
{
    private readonly string[] _customAreaFormats = new string[]
    {
        "/Views/{2}/{1}/{0}.cshtml"
    };

    public CustomEngine(
        IRazorPageFactory pageFactory,
        IRazorViewFactory viewFactory,
        IOptions<RazorViewEngineOptions> optionsAccessor,
        IViewLocationCache viewLocationCache)
        : base(pageFactory, viewFactory, optionsAccessor, viewLocationCache)
    {
    }

    public override IEnumerable<string> AreaViewLocationFormats =>
        _customAreaFormats.Concat(base.AreaViewLocationFormats);
}

This will create an additional area format, which matches the use case of {areaName}/{controller}/{view}.

Second, register the engine in the ConfigureServices method of the Startup.cs class:

public void ConfigureServices(IServiceCollection services)
{
    // Add custom engine (must be BEFORE services.AddMvc() call)
    services.AddSingleton<IRazorViewEngine, CustomEngine>();

    // Add framework services.
    services.AddMvc();
}

Thirdly, add area routing to your MVC routes, in the Configure method:

app.UseMvc(routes =>
{
    // add area routes
    routes.MapRoute(name: "areaRoute",
        template: "{area:exists}/{controller}/{action}",
        defaults: new { controller = "Home", action = "Index" });

    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");
});

Lastly, change your ProductController class to use the AreaAttribute:

[Area("admin")]
public class ProductController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

Now, your application structure can look like this:

sample project structure

Bacteriology answered 20/4, 2016 at 16:33 Comment(2)
Will - thanks for this solution. I can confirm that it works. After additional research I have come up with another way that I think is even simpler by injecting the ExpandViewLocations class. I will credit your solution as the answer because it does work and you deserve the credit for your thorough response.Erastes
This answer is no longer valid for current ASP.Net Core projects, since AreaViewLocationFormats no longer exists. I think this was moved to RazorViewEngineOptions now.Robb
C
3

Though other answers may be correct, I'd like to add something that is a little bit more "basic":

  • There is (a lot of) implicit routing behavior in MVC .NET
  • You can make everything explicit also

So, how does that work for .NET MVC?

Default

  • The default "route" is protocol://server:port/ , e.g. http://localhost:607888/ If you don't have any controller with a explicit route, and don't define any startup defaults, that wont work. This will:

    app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Special}/{action=Index}"); });

Controller Routes

And if you add a class SpecialController : Controller with a Index() method, your http://localhost:.../ will and up there. Note: NameController => post fix Controller is left out, implicit naming convention

If you rather define your routes explicit on the controllers, use this:

[Route("Special")]//explicit route
public class SpecialController : Controller
{ ....

=> http://localhost:<port>/Special will end up on this controller

For mapping http requests to controller methods, you can also add explicit [Route(...)] information to your Methods:

// GET: explicit route page
[HttpGet("MySpecialIndex")]
public ActionResult Index(){...}

=> http://localhost:<port>/Special/MySpecialIndex will end up on SpecialController.Index()

View routes

Now suppose your Views folder is like this:

Views\
   Special1\
          Index1.cshtml
   Special\
          Index.cshtml

How does the Controller "finds" its way to the views? The example here is

[Route("Special")]//explicit route
public class Special1Controller : Controller
{
    // GET: Default route page
    [HttpGet]
    public ActionResult Index()
    {
        //
        // Implicit path, implicit view name: Special1<Controller> -> View  = Views/Special/Index.cshtml
        //
        //return View();

        //
        // Implicit path, explicit view name, implicit extention 
        // Special <Controller> -> View  = Views/Special/Index.cshtml
        //
         //return View("Index");

        //
        // Everything explcit
        //
        return View("Views/Special1/Index1.cshtml");
    }

So, we have:

return View(); => everything implicit, take Method name as view, controller path as view path etc. http://<>:<>/Special => Method = Index(), View = /Views/Special/Index.cshtml

return View("Index"); //Explicit view name, implicit paths and extension => Method = Special1Controller.Index(), View = /Views/Special/Index.cshtml

return View("Views/Special1/Index1.cshtml"); // method implicit, view explicit => http://<>:<>/Special, Method = Special1Controller.Index(), View = /Views/Special1/Index1.cshtml

And if you combine explicit mapping to methods and views: => http://<>:<>/Special/MySpecialIndex, Method = Special1Controller.Index(), View = /Views/Special1/Index1.cshtml

Then finally, why would you make everything implicit? The pros are less administration that is error prone, and you force some clean administration in your naming and setup of folders The con is a lot of magic is going on, that everybody needs to understand.

Then why would you make everything explicit? Pros: This is more readable for "everyone". No need to know all implicit rules. And more flexibility for changing routes and maps explicitly. The chance on conflicts between controllers and route paths is also a little less.

Finally: of course you can mix explicit and implicit routing.

My preference would be everything explicit. Why? I like explicit mappings and separation of concerns. class names and method names can have a naming convention, without interference with your request naming conventions. E.g. suppose my classes/methods are camelCase, my queries lowercase, then that would work nicely: http://..:../whatever/something and ControllerX.someThing (Keep in mind, Windows is kind of case insensitive, Linux by no means is! And modern .netcore Docker components may end up on a Linux platform!) I also don't like "big monolithic" classes with X000 lines of code. Splitting your controllers but not your queries works perfectly, by giving them explicit the same http query routes. Bottom line: know how it works, and choose a strategy wisely!

Cestoid answered 18/3, 2020 at 9:35 Comment(0)
M
1

So after digging, I think I found the issue on a different stackoverflow. I had the same issue, and upon copying in the ViewImports file from the non area section, the links started to function as anticipated.
As seen here: Asp.Net core 2.0 MVC anchor tag helper not working
The other solution was to copy at the view level:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Munitions answered 8/6, 2019 at 22:34 Comment(0)
S
1

According to the question, I think it is worth mentioning how to do so when you use areas in your routes.

I credit most of this answer to @Mike's answer.

In my case, I have a controller with a name that matches the area name. I use a custom convention to alter the controller's name to "Home" so that I can create a default route {area}/{controller=Home}/{action=Index}/{id?} in MapControllerRoute.

Why I landed on this SO question was because now Razor wasn't searching my original controller's name view folders, therefore not finding my view.

I simply had to add this bit of code to ConfigureServices (the difference here is the use of AreaViewLocationFormats):

services.AddMvc().AddRazorOptions(options => 
    options.AreaViewLocationFormats.Add("/Areas/{2}/Views/{2}/{0}" + RazorViewEngine.ViewExtension));
// as already noted, {0} = action name, {1} = controller name, {2} = area name
Simonne answered 7/4, 2020 at 17:54 Comment(0)
P
1

I wish and it probably could work as being defined on the controller.

Anyway, current day answer as of mid 2023 is pretty simple with the new default Program.cs

builder.Services.confiure<RazorViewEngineOptipns>(o => 
{
    o.ViewLocationFormats.Add( "/your/custom/path/{1}/{0}" + RazorViewEngine.ViewExtension);
});
Plainsman answered 16/5, 2023 at 20:1 Comment(0)
L
0

You can combine two actions to keep things DRY.

  1. Call Controller.View(string, object?) with the view path instead of the name for arg 1. (see Brian Rizo´s answer)
  2. Overwrite above method in your controller class to construct the path from view name and then call the base method.

In all your controller action methods you now can keep using the view name when returning a view.

    [Route("Wizard/UploadStuff/[action]")]
    public class UploadStuffController : Controller
    {
        //overwrites base method to incorporate common path 
        public new ViewResult View(string name, object? model)
        {
            var path = $"Views/Wizard/UploadStuff/{name}.cshtml";
            return base.View(path, model);
        }

        public async Task<IActionResult> Step1(string param1)
        {
            UploadStuffModel model;
            
            //... more code            
            
            return View("Step1", model); 
        }

        //... more action methods
  }
Lackluster answered 21/12, 2022 at 11:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.