Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Change component view location in Asp.Net 5

On ASP.NET 5 a Component view must be in one of two places:

Views/NameOfControllerUsingComponent/Components/ComponentName/Default.cshtml
Views/Shared/Components/ComponentName/Default.cshtml

Is there a way to change this to:

Views/NameOfControllerUsingComponent/Components/ComponentName.cshtml
Views/Shared/Components/ComponentName.cshtml

So basically, remove the folder ComponentName and change the view name from Default.cshtml to ComponentName.cshtml.

For me it makes more sense ... Is it possible?

like image 300
Miguel Moura Avatar asked Feb 08 '23 15:02

Miguel Moura


2 Answers

That convention is only applied if you create a view component that derives from the base ViewComponent provided by the framework.

That class defines the View helpers, which return a ViewViewComponentResult:

public ViewViewComponentResult View<TModel>(string viewName, TModel model)
{
    var viewData = new ViewDataDictionary<TModel>(ViewData, model);
    return new ViewViewComponentResult
    {
        ViewEngine = ViewEngine,
        ViewName = viewName,
        ViewData = viewData
    };
}

The ViewViewComponentResult is where the conventions are defined:

private const string ViewPathFormat = "Components/{0}/{1}";
private const string DefaultViewName = "Default";

public async Task ExecuteAsync(ViewComponentContext context)
{
    ...

    string qualifiedViewName;
    if (!isNullOrEmptyViewName &&
        (ViewName[0] == '~' || ViewName[0] == '/'))
    {
        // View name that was passed in is already a rooted path, the view engine will handle this.
        qualifiedViewName = ViewName;
    }
    else
    {
        // This will produce a string like:
        //
        //  Components/Cart/Default
        //
        // The view engine will combine this with other path info to search paths like:
        //
        //  Views/Shared/Components/Cart/Default.cshtml
        //  Views/Home/Components/Cart/Default.cshtml
        //  Areas/Blog/Views/Shared/Components/Cart/Default.cshtml
        //
        // This supports a controller or area providing an override for component views.
        var viewName = isNullOrEmptyViewName ? DefaultViewName : ViewName;

        qualifiedViewName = string.Format(
            CultureInfo.InvariantCulture,
            ViewPathFormat,
            context.ViewComponentDescriptor.ShortName,
            viewName);
    }

    ...

}

Notice that if you return from your view component the full path to a view as the view name, then the view component will use the specified view. Something like:

return View("~/Views/Shared/Components/ComponentName.cshtml")

Since there is no way to modify the conventions in ViewViewComponentResult and your approach would only work for view components with a single view, you could build something using the root view paths approach:

  • Create your own ViewComponent class extending the existing one.
  • Add new helper methods or hide the existing View methods to return a view using a full path:

    public ViewViewComponentResult MyView<TModel>(TModel model)
    {
        var viewName = string.Format(
                "~/Views/Shared/Components/{0}.cshtml", 
                this.ViewComponentContext.ViewComponentDescriptor.ShortName)
        return View(viewName, model);
    }
    
  • If you add new methods you might be able to add them as extension methods of ViewComponent instead of having to create your own class.

Another alternative would be creating a class SingleViewViewComponent copying the code for ViewComponent but replacing the implementation of ViewViewComponentResult View<TModel>(string viewName, TModel model). Then when creating your view components, you would inherit from SingleViewViewComponent instead of ViewComponent.

like image 50
Daniel J.G. Avatar answered Feb 17 '23 02:02

Daniel J.G.


Took me a weekend to finally find a way around this that didn't involve writing a custom ViewComponentResult.

in MVC .Net Core, you can add your own IViewLocationExpander to the RazorViewEngineOptions in your startup.cs's ConfigureServices:

public void ConfigureServices(IServiceCollection services)
    {
        ...

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

    }

This allows you to add custom Paths that are used in conjuction with the ViewLocationExpanderContext ViewName ({0}), ControllerName ({1}).

The main issue is that you can't alter the context's values, which makes it seemingly impossible to change the default View Component's ViewName of Component/ComponentName/Default

Seemingly impossible

Here's the trick, the ExpandViewLocations is called with each View(), each time it doesn't have a fully qualified view path. Which means you can add custom logic. What I did was add a catch to detect ViewComponents in the PopulateValues method, then added to the context.Values dictionary, and then if that dictionary has those custom values, it will prepend to the Paths the list of paths that use my generated view name instead of the context.

It's fully reverse compatible, and shouldn't impact performance one bit.

public class CustomLocationExpander : IViewLocationExpander
{
    private const string _CustomViewPath = "CustomViewPath";
    private const string _CustomController = "CustomController";
    public void PopulateValues(ViewLocationExpanderContext context)
    {
        Regex DefaultComponentDetector = new Regex(@"^((?:[Cc]omponents))+\/+([\w\.]+)\/+(.*)");

        
        /*
         * 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 (DefaultComponentMatch.Success)
        {
            // Will render Components/ComponentName as the new view name
            context.Values.Add(_CustomViewPath, string.Format("{0}/{1}", DefaultComponentMatch.Groups[1].Value, DefaultComponentMatch.Groups[2].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",

            // Handles My Custom rendered views
            "/{0}.cshtml"
            };

        // Add "Hard Coded" custom view paths to checks, along with the normal default view paths for backward compatibility
        if (context.Values.ContainsKey(_CustomViewPath))
        {
            // Generate full View Paths with my custom View Name and Controller Name

            var CombinedPaths = new List<string>(Paths.Select(x => string.Format(x, context.Values[_CustomViewPath], context.Values[_CustomController], "")));
            // Add in original paths for backward compatibility
            CombinedPaths.AddRange(Paths);

            return CombinedPaths;
        }
        
        // Returns the normal view paths
        return Paths;
    }
}
like image 27
Trevor F Avatar answered Feb 17 '23 02:02

Trevor F